Skip to main content

quillmark_content/
ops.rs

1//! Mark and line op channels — structural edits separate from text splices.
2//!
3//! [`MarkOp`] and [`LineOp`] apply after [`Content::apply_text_delta`] in one
4//! bundle. Mark ranges are in **final-text coordinates**: mark ops run after
5//! line ops and validate against the post-line-op length, so a producer
6//! computes them in the only frame it can — the text as it stands once the
7//! delta and line ops have landed. Line split/join splice a `\n` in `text` and
8//! rebase marks through that one-char change with
9//! [`Delta::map_pos`](crate::delta::Delta::map_pos), the same mapping the
10//! text-delta channel uses, so a mark's coordinates track the splice rather
11//! than drifting.
12
13use crate::delta::{Assoc, Delta, Op};
14use crate::model::{Container, Island, Line, LineKind, Mark, MarkKind, Content, Usv, ISLAND_SLOT};
15use crate::normalize::is_bidi_char;
16use crate::usv::char_to_byte;
17use std::borrow::Cow;
18
19/// A mark edit in final-text coordinates (post-delta, post-line-op).
20#[derive(Debug, Clone, PartialEq)]
21pub enum MarkOp {
22    /// Add a mark over `[start, end)`.
23    Add {
24        start: Usv,
25        end: Usv,
26        kind: MarkKind,
27    },
28    /// Un-format `kind` over `[start, end)`: subtract the range from each
29    /// overlapping same-kind *formatting* mark, keeping the non-overlapping
30    /// fragments (a mid-run removal punches a hole; `normalize` drops any
31    /// zero-width fragment an edge-aligned removal leaves). Non-formatting
32    /// (identity/unknown) handles can't be range-fragmented, so an overlapping
33    /// one is dropped whole — anchors normally go through [`MarkOp::RemoveAnchor`].
34    Remove {
35        start: Usv,
36        end: Usv,
37        kind: MarkKind,
38    },
39    /// Drop one identity anchor by id.
40    RemoveAnchor { id: String },
41}
42
43/// A line/block edit. Split/join splice `\n` in `text`; set ops touch metadata
44/// only.
45#[derive(Debug, Clone, PartialEq)]
46pub enum LineOp {
47    /// Paragraph break at `at`: insert `\n` and split the line metadata.
48    Split { at: Usv },
49    /// Join line `line` with the next — remove the `\n` between them.
50    Join { line: usize },
51    /// Replace a line's block role.
52    SetKind { line: usize, kind: LineKind },
53    /// Replace a line's container path.
54    SetContainers {
55        line: usize,
56        containers: Vec<Container>,
57    },
58    /// Set (or clear) a line's `continues` flag — whether it continues the
59    /// previous line's block across a within-block hard break (a markdown hard
60    /// break, a code fence's interior line) rather than starting a new block.
61    /// The op-grained twin of the value that `install` already round-trips:
62    /// `split`/`join`/text-delta `\n` insertion all mint `continues: false`
63    /// lines, so without this a hard break or a new code-fence interior line is
64    /// unreachable op-wise and falls back to a whole-`install` (losing that
65    /// edit's identity anchors). Setting `continues: true` on line 0 is
66    /// [`ApplyError::FirstLineContinues`] (nothing precedes it to continue).
67    SetContinues { line: usize, continues: bool },
68}
69
70// ── Change-bundle wire (mark / line op ⇄ JSON) ──────────────────────────────
71//
72// [`Delta`] serializes through serde derive; [`MarkOp`] and [`LineOp`] carry
73// [`MarkKind`] / [`LineKind`] / [`Container`], whose canonical JSON is the
74// hand-written `serial` encoding (the `{type, …}` / `{kind, …}` discriminants a
75// `ContentMark` / `ContentLine` already uses). These converters reuse that
76// exact vocabulary so the `applyChange` bundle speaks the same shapes the
77// content read surface does, rather than a second serde-derived dialect. The
78// language bindings call them to lower a JS/Python bundle to core ops.
79
80use crate::serial::{
81    container_from_value, container_to_value, line_kind_from_value, line_kind_to_value,
82    mark_from_value, mark_to_value, ParseError,
83};
84use serde_json::{Map, Value};
85
86/// Encode a [`MarkOp`] to its wire object. `Add`/`Remove` carry the mark
87/// vocabulary (`{op, start, end, type, …}`); `RemoveAnchor` is `{op, id}`.
88pub fn mark_op_to_value(op: &MarkOp) -> Value {
89    let mut m = Map::new();
90    match op {
91        MarkOp::Add { start, end, kind } => {
92            m.insert("op".into(), "add".into());
93            merge_mark(&mut m, *start, *end, kind);
94        }
95        MarkOp::Remove { start, end, kind } => {
96            m.insert("op".into(), "remove".into());
97            merge_mark(&mut m, *start, *end, kind);
98        }
99        MarkOp::RemoveAnchor { id } => {
100            m.insert("op".into(), "removeAnchor".into());
101            m.insert("id".into(), Value::String(id.clone()));
102        }
103    }
104    Value::Object(m)
105}
106
107/// Merge a mark's `{start, end, type, …}` fields into an op object, reusing the
108/// canonical `serial` mark encoding.
109fn merge_mark(m: &mut Map<String, Value>, start: Usv, end: Usv, kind: &MarkKind) {
110    let mark = Mark {
111        start,
112        end,
113        kind: kind.clone(),
114    };
115    if let Value::Object(fields) = mark_to_value(&mark) {
116        m.extend(fields);
117    }
118}
119
120/// Decode a [`MarkOp`] from its wire object. Dispatches on `op`; `add`/`remove`
121/// read the mark vocabulary through [`mark_from_value`].
122pub fn mark_op_from_value(v: &Value) -> Result<MarkOp, ParseError> {
123    let o = v.as_object().ok_or(ParseError::Shape("mark op"))?;
124    match o.get("op").and_then(Value::as_str) {
125        Some("add") => {
126            let mark = mark_from_value(v)?;
127            Ok(MarkOp::Add {
128                start: mark.start,
129                end: mark.end,
130                kind: mark.kind,
131            })
132        }
133        Some("remove") => {
134            let mark = mark_from_value(v)?;
135            Ok(MarkOp::Remove {
136                start: mark.start,
137                end: mark.end,
138                kind: mark.kind,
139            })
140        }
141        Some("removeAnchor") => Ok(MarkOp::RemoveAnchor {
142            id: o
143                .get("id")
144                .and_then(Value::as_str)
145                .ok_or(ParseError::Shape("removeAnchor id"))?
146                .to_string(),
147        }),
148        _ => Err(ParseError::Shape("mark op kind")),
149    }
150}
151
152/// Encode a [`LineOp`] to its wire object. `SetKind` flattens the line-kind
153/// discriminant (`kind`/`level`/`lang`) alongside `op`/`line`.
154pub fn line_op_to_value(op: &LineOp) -> Value {
155    let mut m = Map::new();
156    match op {
157        LineOp::Split { at } => {
158            m.insert("op".into(), "split".into());
159            m.insert("at".into(), Value::from(*at));
160        }
161        LineOp::Join { line } => {
162            m.insert("op".into(), "join".into());
163            m.insert("line".into(), Value::from(*line));
164        }
165        LineOp::SetKind { line, kind } => {
166            m.insert("op".into(), "setKind".into());
167            m.insert("line".into(), Value::from(*line));
168            if let Value::Object(fields) = line_kind_to_value(kind) {
169                m.extend(fields);
170            }
171        }
172        LineOp::SetContainers { line, containers } => {
173            m.insert("op".into(), "setContainers".into());
174            m.insert("line".into(), Value::from(*line));
175            m.insert(
176                "containers".into(),
177                Value::Array(containers.iter().map(container_to_value).collect()),
178            );
179        }
180        LineOp::SetContinues { line, continues } => {
181            m.insert("op".into(), "setContinues".into());
182            m.insert("line".into(), Value::from(*line));
183            m.insert("continues".into(), Value::Bool(*continues));
184        }
185    }
186    Value::Object(m)
187}
188
189/// Decode a [`LineOp`] from its wire object. Dispatches on `op`.
190pub fn line_op_from_value(v: &Value) -> Result<LineOp, ParseError> {
191    let o = v.as_object().ok_or(ParseError::Shape("line op"))?;
192    let line = || {
193        o.get("line")
194            .and_then(Value::as_u64)
195            .map(|n| n as usize)
196            .ok_or(ParseError::Shape("line op line"))
197    };
198    match o.get("op").and_then(Value::as_str) {
199        Some("split") => Ok(LineOp::Split {
200            at: o
201                .get("at")
202                .and_then(Value::as_u64)
203                .map(|n| n as usize)
204                .ok_or(ParseError::Shape("split at"))?,
205        }),
206        Some("join") => Ok(LineOp::Join { line: line()? }),
207        Some("setKind") => Ok(LineOp::SetKind {
208            line: line()?,
209            kind: line_kind_from_value(v)?,
210        }),
211        Some("setContainers") => Ok(LineOp::SetContainers {
212            line: line()?,
213            containers: o
214                .get("containers")
215                .and_then(Value::as_array)
216                .ok_or(ParseError::Shape("setContainers containers"))?
217                .iter()
218                .map(container_from_value)
219                .collect::<Result<_, _>>()?,
220        }),
221        Some("setContinues") => Ok(LineOp::SetContinues {
222            line: line()?,
223            continues: o
224                .get("continues")
225                .and_then(Value::as_bool)
226                .ok_or(ParseError::Shape("setContinues continues"))?,
227        }),
228        _ => Err(ParseError::Shape("line op kind")),
229    }
230}
231
232/// Lower a committed change **bundle** object (`{delta?, lineOps?, markOps?}`) to
233/// core ops — the whole-bundle reader the `applyChange` verb needs, so each
234/// binding lowers a JS/Python bundle in one call instead of re-deriving the
235/// delta/op extraction. A missing `delta` is the identity (no text change); a
236/// missing/`null` op array is empty. Both camelCase (`lineOps`) and snake_case
237/// (`line_ops`) keys are accepted, so the one reader serves the wasm (camelCase)
238/// and Python (either) surfaces. The error is a message string the binding wraps
239/// in its own error type.
240#[allow(clippy::type_complexity)]
241pub fn change_bundle_from_value(
242    v: &Value,
243) -> Result<(Delta, Vec<LineOp>, Vec<MarkOp>), String> {
244    let obj = v
245        .as_object()
246        .ok_or("bundle must be an object { delta?, lineOps?, markOps? }")?;
247    let get = |snake: &str, camel: &str| obj.get(snake).or_else(|| obj.get(camel));
248    let delta = match get("delta", "delta") {
249        Some(Value::Null) | None => Delta { ops: Vec::new() },
250        Some(d) => serde_json::from_value(d.clone()).map_err(|e| format!("invalid delta: {e}"))?,
251    };
252    let line_ops = op_array(get("line_ops", "lineOps"), line_op_from_value, "lineOps")?;
253    let mark_ops = op_array(get("mark_ops", "markOps"), mark_op_from_value, "markOps")?;
254    Ok((delta, line_ops, mark_ops))
255}
256
257/// Lower an optional JSON array of op objects through `convert` (missing/`null`
258/// → empty), naming `what` in any shape-error message. The list twin shared by
259/// [`change_bundle_from_value`]'s line- and mark-op channels.
260fn op_array<T>(
261    value: Option<&Value>,
262    convert: impl Fn(&Value) -> Result<T, ParseError>,
263    what: &str,
264) -> Result<Vec<T>, String> {
265    let Some(value) = value else {
266        return Ok(Vec::new());
267    };
268    if value.is_null() {
269        return Ok(Vec::new());
270    }
271    let arr = value
272        .as_array()
273        .ok_or_else(|| format!("{what} must be an array"))?;
274    arr.iter()
275        .map(|v| convert(v).map_err(|e| format!("invalid {what}: {e}")))
276        .collect()
277}
278
279/// Why an apply failed — range or line index out of bounds, or invariants
280/// broken before normalization could repair them.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum ApplyError {
283    MarkOutOfRange {
284        start: Usv,
285        end: Usv,
286        len: Usv,
287    },
288    LineOutOfRange {
289        line: usize,
290        lines: usize,
291    },
292    SplitPositionOutOfRange {
293        at: Usv,
294        len: Usv,
295    },
296    SplitAtNewline {
297        at: Usv,
298    },
299    LineCountMismatch {
300        lines: usize,
301        segments: usize,
302    },
303    /// A [`LineOp::SetContinues`] tried to set `continues: true` on line 0, which
304    /// has nothing before it to continue — the apply-time twin of the
305    /// [`Invariant::FirstLineContinues`](crate::model::Invariant::FirstLineContinues)
306    /// validation error, refused here because `normalize` does not repair it.
307    FirstLineContinues,
308    /// The text delta's expected base length disagreed with the content —
309    /// it was built against a different revision.
310    DeltaBaseMismatch {
311        expected: usize,
312        actual: usize,
313    },
314    /// An `Op::Insert` carried a raw [`ISLAND_SLOT`]. Islands are structurally
315    /// uneditable through the text channel — a slot inserted here would have no
316    /// backing [`Island`], an orphaned-slot invariant violation. Islands are
317    /// created through their own channel, never a text splice.
318    IslandSlotInInsert,
319}
320
321impl Content {
322    /// Splice `text` via `delta`, rebase marks, sync `lines` to `\n` changes,
323    /// cascade island removal for any deleted slot, then normalize.
324    ///
325    /// Islands stay in lockstep with their [`ISLAND_SLOT`] chars: a delta that
326    /// *deletes* a slot drops the corresponding [`Island`] (the content goes
327    /// away with its slot); a delta that *inserts* a raw slot is rejected
328    /// ([`ApplyError::IslandSlotInInsert`]) — islands are created through their
329    /// own channel, never a text splice, so a slot arriving here would orphan.
330    ///
331    /// Inserted text is sanitized first: `\r` and Unicode bidi controls — the
332    /// chars [`Content::validate`] forbids — are stripped, mirroring the
333    /// normalization `import` applies at the string boundary. The text-delta
334    /// channel is the *other* way text enters the content, so without this an
335    /// insert of `\r` or a bidi control returned `Ok` while leaving a content
336    /// that fails `validate()` (see issue #899).
337    pub fn apply_text_delta(&mut self, delta: &Delta) -> Result<(), ApplyError> {
338        self.apply_text_delta_inner(delta)?;
339        self.normalize();
340        Ok(())
341    }
342
343    /// [`apply_text_delta`](Self::apply_text_delta) without the terminal
344    /// normalize — the stage [`apply_field_change`](Self::apply_field_change)
345    /// runs so a committed bundle canonicalizes once at the end, not after each
346    /// op.
347    fn apply_text_delta_inner(&mut self, delta: &Delta) -> Result<(), ApplyError> {
348        // Reject before mutating: a raw slot in an insert would create a slot
349        // with no backing island. Checked up front so the content is untouched
350        // on this error.
351        for op in &delta.ops {
352            if let Op::Insert(s) = op {
353                if s.contains(ISLAND_SLOT) {
354                    return Err(ApplyError::IslandSlotInInsert);
355                }
356            }
357        }
358
359        // Strip the chars `validate()` forbids (`\r`, bidi controls) from every
360        // insert before they reach the content. Stripping — not rejecting —
361        // mirrors `import`: these are content to normalize away, unlike a raw
362        // slot, which has no backing island and must be refused. Sanitizing the
363        // whole delta up front keeps `try_apply` / `map_pos` / line+island sync
364        // in agreement on one cleaned op stream; a clean delta (every keystroke)
365        // is borrowed through untouched, so the hot path skips the clone.
366        let sanitized = sanitize_inserts(delta);
367        let delta = sanitized.as_ref();
368
369        let old_chars: Vec<char> = self.text.chars().collect();
370        let old_lines = self.lines.clone();
371        // A splice may name only the region it changes: `try_apply` retains the
372        // untouched remainder implicitly, so a bare prepend applies against the
373        // whole content. An over-long delta (consuming more base than exists)
374        // still fails the base-length check.
375        let new_text = delta
376            .try_apply(&self.text)
377            .map_err(|e| ApplyError::DeltaBaseMismatch {
378                expected: e.expected,
379                actual: e.actual,
380            })?;
381
382        self.rebase_marks(delta);
383        let new_len = new_text.chars().count();
384        self.marks.retain(|m| {
385            m.start <= m.end
386                && m.end <= new_len
387                && (m.start < m.end || !m.kind.is_formatting())
388        });
389
390        self.text = new_text;
391        self.lines = sync_lines_for_delta(&old_chars, old_lines, delta);
392        let old_islands = std::mem::take(&mut self.islands);
393        self.islands = sync_islands_for_delta(&old_chars, old_islands, delta);
394        if self.lines.len() != self.segment_count() {
395            return Err(ApplyError::LineCountMismatch {
396                lines: self.lines.len(),
397                segments: self.segment_count(),
398            });
399        }
400        Ok(())
401    }
402
403    /// Rebase every mark's range through `delta`'s
404    /// [`map_pos`](crate::delta::Delta::map_pos): a range mark's start biases
405    /// `After` and its end `Before` (an insertion at either edge grows text
406    /// *outside* the span), a point (zero-width) mark biases `Before`. The one
407    /// mapping the text-delta channel and line split/join both rebase marks by.
408    fn rebase_marks(&mut self, delta: &Delta) {
409        for m in &mut self.marks {
410            if m.start == m.end {
411                let p = delta.map_pos(m.start, Assoc::Before);
412                m.start = p;
413                m.end = p;
414            } else {
415                m.start = delta.map_pos(m.start, Assoc::After);
416                m.end = delta.map_pos(m.end, Assoc::Before);
417            }
418        }
419    }
420
421    /// Apply mark ops in final-text coordinates, then normalize.
422    pub fn apply_mark_ops(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
423        self.apply_mark_ops_inner(ops)?;
424        self.normalize();
425        Ok(())
426    }
427
428    /// [`apply_mark_ops`](Self::apply_mark_ops) without the terminal normalize —
429    /// the bundle's final stage, canonicalized once by
430    /// [`apply_field_change`](Self::apply_field_change).
431    fn apply_mark_ops_inner(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
432        let len = self.len_usv();
433        for op in ops {
434            match op {
435                MarkOp::Add { start, end, kind } => {
436                    if *start > *end || *end > len {
437                        return Err(ApplyError::MarkOutOfRange {
438                            start: *start,
439                            end: *end,
440                            len,
441                        });
442                    }
443                    if kind.is_formatting() && start == end {
444                        return Err(ApplyError::MarkOutOfRange {
445                            start: *start,
446                            end: *end,
447                            len,
448                        });
449                    }
450                    self.marks.push(Mark {
451                        start: *start,
452                        end: *end,
453                        kind: kind.clone(),
454                    });
455                }
456                MarkOp::Remove { start, end, kind } => {
457                    if *start > *end || *end > len {
458                        return Err(ApplyError::MarkOutOfRange {
459                            start: *start,
460                            end: *end,
461                            len,
462                        });
463                    }
464                    let mut next = Vec::with_capacity(self.marks.len());
465                    for m in self.marks.drain(..) {
466                        // Untouched: a different kind, or no overlap with the
467                        // removed range.
468                        if m.kind != *kind || !ranges_overlap(m.start, m.end, *start, *end) {
469                            next.push(m);
470                            continue;
471                        }
472                        // Identity/unknown handles have no range algebra to
473                        // subtract — drop the overlapping one whole.
474                        if !kind.is_formatting() {
475                            continue;
476                        }
477                        // Formatting: subtract [start, end), re-emitting the
478                        // surviving fragments. An edge-aligned removal yields a
479                        // zero-width fragment here; `normalize` drops it.
480                        if m.start < *start {
481                            next.push(Mark {
482                                start: m.start,
483                                end: *start,
484                                kind: m.kind.clone(),
485                            });
486                        }
487                        if *end < m.end {
488                            next.push(Mark {
489                                start: *end,
490                                end: m.end,
491                                kind: m.kind.clone(),
492                            });
493                        }
494                    }
495                    self.marks = next;
496                }
497                MarkOp::RemoveAnchor { id } => {
498                    self.marks
499                        .retain(|m| !matches!(&m.kind, MarkKind::Anchor { id: aid } if aid == id));
500                }
501            }
502        }
503        Ok(())
504    }
505
506    /// Apply line ops — split/join splice `\n`; set ops touch metadata only.
507    pub fn apply_line_ops(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
508        self.apply_line_ops_inner(ops)?;
509        self.normalize();
510        Ok(())
511    }
512
513    /// [`apply_line_ops`](Self::apply_line_ops) without the terminal normalize —
514    /// a bundle stage canonicalized once by
515    /// [`apply_field_change`](Self::apply_field_change).
516    fn apply_line_ops_inner(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
517        for op in ops {
518            match op {
519                LineOp::Split { at } => self.split_line(*at)?,
520                LineOp::Join { line } => self.join_line(*line)?,
521                LineOp::SetKind { line, kind } => {
522                    let line = self.line_mut(*line)?;
523                    line.kind = kind.clone();
524                }
525                LineOp::SetContainers { line, containers } => {
526                    let line = self.line_mut(*line)?;
527                    line.containers = containers.clone();
528                }
529                LineOp::SetContinues { line, continues } => {
530                    // Line 0 has nothing before it to continue: setting the flag
531                    // there would forge the `FirstLineContinues` invariant that
532                    // `normalize` does not repair. Reject before the write so the
533                    // content stays valid (`apply_field_change` stages line ops on
534                    // a scratch copy, so this leaves `self` untouched).
535                    if *line == 0 && *continues {
536                        return Err(ApplyError::FirstLineContinues);
537                    }
538                    let l = self.line_mut(*line)?;
539                    l.continues = *continues;
540                }
541            }
542        }
543        Ok(())
544    }
545
546    /// One committed field edit bundle: text delta, then line ops, then marks,
547    /// canonicalized by a single terminal [`normalize`](Self::normalize).
548    ///
549    /// All-or-nothing: on any op's error `self` is left exactly as it was, so a
550    /// caller need not snapshot-and-restore around a failed bundle. A bundle
551    /// carrying line or mark ops has several fallible stages that would
552    /// otherwise partially commit, so it is staged on a scratch copy and
553    /// swapped in only once every stage succeeds. The pure-text-delta path (the
554    /// per-keystroke hot path) skips the clone: `apply_text_delta` validates the
555    /// delta before mutating, so it is already atomic on the errors a caller can
556    /// provoke.
557    ///
558    /// The stages run on their non-normalizing inner forms and `normalize` runs
559    /// once at the end. One terminal normalize suffices because split/join
560    /// rebase marks through their `\n` splice
561    /// ([`map_pos`](crate::delta::Delta::map_pos) semantics): the
562    /// formatting-edge `\n`-trim then commutes with the line ops (trim-per-stage
563    /// and trim-once converge), and `MarkOp::Remove` is coverage-set
564    /// subtraction, which commutes with `normalize`'s same-kind union
565    /// (`(A ∪ B) \ R = (A\R) ∪ (B\R)`). One canonicalization point, one pass.
566    pub fn apply_field_change(
567        &mut self,
568        text_delta: &Delta,
569        line_ops: &[LineOp],
570        mark_ops: &[MarkOp],
571    ) -> Result<(), ApplyError> {
572        if line_ops.is_empty() && mark_ops.is_empty() {
573            return self.apply_text_delta(text_delta);
574        }
575        let mut scratch = self.clone();
576        scratch.apply_text_delta_inner(text_delta)?;
577        scratch.apply_line_ops_inner(line_ops)?;
578        scratch.apply_mark_ops_inner(mark_ops)?;
579        scratch.normalize();
580        *self = scratch;
581        Ok(())
582    }
583
584    fn line_mut(&mut self, line: usize) -> Result<&mut Line, ApplyError> {
585        let lines = self.lines.len();
586        self.lines
587            .get_mut(line)
588            .ok_or(ApplyError::LineOutOfRange { line, lines })
589    }
590
591    fn split_line(&mut self, at: Usv) -> Result<(), ApplyError> {
592        let char_indices: Vec<(usize, char)> = self.text.char_indices().collect();
593        let len = char_indices.len();
594        if at > len {
595            return Err(ApplyError::SplitPositionOutOfRange { at, len });
596        }
597        if at > 0 && char_indices[at - 1].1 == '\n' {
598            return Err(ApplyError::SplitAtNewline { at });
599        }
600        if at < len && char_indices[at].1 == '\n' {
601            return Err(ApplyError::SplitAtNewline { at });
602        }
603
604        // `at`'s newline-adjacency neighbors, `at`'s byte offset, and the
605        // newline count before `at` (== the post-insert line index, since the
606        // insertion lands at index `at`, not before it) all come from this
607        // one pass over `char_indices`, instead of four separate text scans.
608        let byte = char_indices.get(at).map_or(self.text.len(), |&(b, _)| b);
609        let line_idx = char_indices[..at].iter().filter(|&(_, c)| *c == '\n').count();
610        self.text.insert(byte, '\n');
611
612        // Rebase marks through the one-char `\n` insertion — the same map_pos
613        // rule the text-delta channel uses, so a split does not drift a mark's
614        // coordinates (a mark spanning `at` grows by the inserted char; the
615        // terminal normalize trims any `\n` edge it lands on).
616        self.rebase_marks(&Delta {
617            ops: vec![Op::Retain(at), Op::Insert("\n".to_string())],
618        });
619
620        let template = self
621            .lines
622            .get(line_idx)
623            .cloned()
624            .unwrap_or_else(default_para_line);
625        let mut new_line = template;
626        new_line.continues = false;
627        self.lines.insert(line_idx + 1, new_line);
628
629        if self.lines.len() != self.segment_count() {
630            return Err(ApplyError::LineCountMismatch {
631                lines: self.lines.len(),
632                segments: self.segment_count(),
633            });
634        }
635        Ok(())
636    }
637
638    fn join_line(&mut self, line: usize) -> Result<(), ApplyError> {
639        if line + 1 >= self.lines.len() {
640            return Err(ApplyError::LineOutOfRange {
641                line,
642                lines: self.lines.len(),
643            });
644        }
645        let nl = newline_at_line_boundary(&self.text, line)?;
646        let byte = char_to_byte(&self.text, nl);
647        self.text.remove(byte);
648
649        // Rebase marks through the one-char `\n` deletion, as the text-delta
650        // channel would: a mark spanning the boundary shrinks by one; one that
651        // covered only the `\n` collapses to zero-width and the terminal
652        // normalize drops it.
653        self.rebase_marks(&Delta {
654            ops: vec![Op::Retain(nl), Op::Delete(1)],
655        });
656
657        self.lines.remove(line + 1);
658
659        if self.lines.len() != self.segment_count() {
660            return Err(ApplyError::LineCountMismatch {
661                lines: self.lines.len(),
662                segments: self.segment_count(),
663            });
664        }
665        Ok(())
666    }
667}
668
669fn default_para_line() -> Line {
670    Line {
671        kind: LineKind::Para,
672        containers: Vec::new(),
673        continues: false,
674    }
675}
676
677fn ranges_overlap(a0: Usv, a1: Usv, b0: Usv, b1: Usv) -> bool {
678    a0 < b1 && b0 < a1
679}
680
681/// A char the content text may not carry (`validate()` rejects it): a bare `\r`
682/// or a Unicode bidi formatting control. `\n` is a real line boundary and a raw
683/// [`ISLAND_SLOT`] is refused separately, so neither belongs here.
684fn insert_forbidden(c: char) -> bool {
685    c == '\r' || is_bidi_char(c)
686}
687
688/// Drop [`insert_forbidden`] chars from every `Op::Insert`, returning the delta
689/// borrowed untouched when no insert carries one (the common keystroke). Mirrors
690/// the forbidden-char stripping `import` applies (`push_text`, `strip_bidi_
691/// formatting`); a raw `\r`/bidi arriving through the text-delta channel would
692/// otherwise persist a content that fails `validate()`.
693fn sanitize_inserts(delta: &Delta) -> Cow<'_, Delta> {
694    let needs_cleaning = delta
695        .ops
696        .iter()
697        .any(|op| matches!(op, Op::Insert(s) if s.chars().any(insert_forbidden)));
698    if !needs_cleaning {
699        return Cow::Borrowed(delta);
700    }
701    let ops = delta
702        .ops
703        .iter()
704        .map(|op| match op {
705            Op::Insert(s) => Op::Insert(s.chars().filter(|c| !insert_forbidden(*c)).collect()),
706            other => other.clone(),
707        })
708        .collect();
709    Cow::Owned(Delta { ops })
710}
711
712/// Walk `delta` over `old_chars` and mirror `\n` insert/delete in `lines`,
713/// building the result in one forward pass — O(old_chars walked + inserts),
714/// no per-`\n` mid-`Vec` `remove`/`insert`.
715///
716/// The cursor sits *in* a line, `cur`; downstream of it is always the untouched
717/// original suffix (`rest`), because a split lands its clone right at the cursor
718/// and a delete drops the next original. So the three `\n` events reduce to:
719/// a retained `\n` finalizes `cur` and pulls the next original into it; a
720/// deleted `\n` drops the next original (merging it in), when one exists; an
721/// inserted `\n` finalizes `cur` and makes a clone (its `continues` cleared) the
722/// new `cur`. `cur == None` is the past-the-end state on a malformed content
723/// (more `\n` than lines), where a split clones a default line.
724fn sync_lines_for_delta(old_chars: &[char], old_lines: Vec<Line>, delta: &Delta) -> Vec<Line> {
725    let cap = old_lines.len();
726    let mut rest = old_lines.into_iter();
727    let mut out: Vec<Line> = Vec::with_capacity(cap);
728    let mut cur: Option<Line> = rest.next();
729    let mut old = 0usize;
730
731    for op in &delta.ops {
732        match op {
733            Op::Retain(n) => {
734                for _ in 0..*n {
735                    if old >= old_chars.len() {
736                        break;
737                    }
738                    if old_chars[old] == '\n' {
739                        out.extend(cur.take());
740                        cur = rest.next();
741                    }
742                    old += 1;
743                }
744            }
745            Op::Delete(n) => {
746                for _ in 0..*n {
747                    if old >= old_chars.len() {
748                        break;
749                    }
750                    // A deleted '\n' merges the next original into `cur` — drop
751                    // it. With no next original there is nothing to drop.
752                    if old_chars[old] == '\n' {
753                        rest.next();
754                    }
755                    old += 1;
756                }
757            }
758            Op::Insert(s) => {
759                for c in s.chars() {
760                    if c == '\n' {
761                        let mut new_line = match cur.take() {
762                            Some(line) => {
763                                let clone = line.clone();
764                                out.push(line);
765                                clone
766                            }
767                            None => default_para_line(),
768                        };
769                        new_line.continues = false;
770                        cur = Some(new_line);
771                    }
772                }
773            }
774        }
775    }
776
777    out.extend(cur);
778    out.extend(rest);
779    out
780}
781
782/// Walk `delta` over `old_chars` and drop any island whose [`ISLAND_SLOT`] char
783/// was deleted (cascade removal — the island's content goes away with its slot).
784/// Islands are stored in slot order, so the Nth slot backs the Nth island; a
785/// deleted slot drops its island and the survivors renumber implicitly. Raw
786/// slot *inserts* are rejected upstream, so an insert never mints a new slot.
787fn sync_islands_for_delta(
788    old_chars: &[char],
789    old_islands: Vec<Island>,
790    delta: &Delta,
791) -> Vec<Island> {
792    let mut keep = vec![true; old_islands.len()];
793    let mut old = 0usize;
794    let mut slot_idx = 0usize;
795
796    for op in &delta.ops {
797        match op {
798            Op::Retain(n) => {
799                for _ in 0..*n {
800                    if old >= old_chars.len() {
801                        break;
802                    }
803                    if old_chars[old] == ISLAND_SLOT {
804                        slot_idx += 1;
805                    }
806                    old += 1;
807                }
808            }
809            Op::Delete(n) => {
810                for _ in 0..*n {
811                    if old >= old_chars.len() {
812                        break;
813                    }
814                    if old_chars[old] == ISLAND_SLOT {
815                        if let Some(k) = keep.get_mut(slot_idx) {
816                            *k = false;
817                        }
818                        slot_idx += 1;
819                    }
820                    old += 1;
821                }
822            }
823            // Inserts add no slots (a raw ISLAND_SLOT insert is rejected before
824            // this walk), so they never touch the island list.
825            Op::Insert(_) => {}
826        }
827    }
828
829    old_islands
830        .into_iter()
831        .zip(keep)
832        .filter_map(|(island, keep)| keep.then_some(island))
833        .collect()
834}
835
836fn newline_at_line_boundary(text: &str, line: usize) -> Result<Usv, ApplyError> {
837    let mut current = 0usize;
838    for (i, c) in text.chars().enumerate() {
839        if c == '\n' {
840            if current == line {
841                return Ok(i);
842            }
843            current += 1;
844        }
845    }
846    Err(ApplyError::LineOutOfRange {
847        line,
848        lines: text.chars().filter(|&c| c == '\n').count() + 1,
849    })
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use crate::delta::diff;
856    use crate::import::from_markdown;
857
858    #[test]
859    fn mark_op_wire_round_trips_each_variant() {
860        let ops = vec![
861            MarkOp::Add {
862                start: 0,
863                end: 3,
864                kind: MarkKind::Strong,
865            },
866            MarkOp::Add {
867                start: 1,
868                end: 2,
869                kind: MarkKind::Link {
870                    url: "https://x".into(),
871                },
872            },
873            MarkOp::Remove {
874                start: 4,
875                end: 6,
876                kind: MarkKind::Anchor { id: "c1".into() },
877            },
878            MarkOp::RemoveAnchor { id: "c2".into() },
879        ];
880        for op in ops {
881            let v = mark_op_to_value(&op);
882            assert_eq!(mark_op_from_value(&v).unwrap(), op, "round-trip: {v}");
883        }
884    }
885
886    #[test]
887    fn line_op_wire_round_trips_each_variant() {
888        let ops = vec![
889            LineOp::Split { at: 5 },
890            LineOp::Join { line: 1 },
891            LineOp::SetKind {
892                line: 0,
893                kind: LineKind::Heading { level: 2 },
894            },
895            LineOp::SetContainers {
896                line: 2,
897                containers: vec![Container::Quote],
898            },
899            LineOp::SetContinues {
900                line: 1,
901                continues: true,
902            },
903            LineOp::SetContinues {
904                line: 3,
905                continues: false,
906            },
907        ];
908        for op in ops {
909            let v = line_op_to_value(&op);
910            assert_eq!(line_op_from_value(&v).unwrap(), op, "round-trip: {v}");
911        }
912    }
913
914    #[test]
915    fn delta_serde_shape() {
916        let d = Delta {
917            ops: vec![Op::Retain(2), Op::Insert("hi".into()), Op::Delete(1)],
918        };
919        let v = serde_json::to_value(&d).unwrap();
920        assert_eq!(
921            v,
922            serde_json::json!({"ops": [{"retain": 2}, {"insert": "hi"}, {"delete": 1}]})
923        );
924        assert_eq!(serde_json::from_value::<Delta>(v).unwrap(), d);
925    }
926
927    #[test]
928    fn apply_text_delta_rebases_marks() {
929        let mut rt = from_markdown("hello").unwrap();
930        rt.marks.push(Mark {
931            start: 1,
932            end: 4,
933            kind: MarkKind::Strong,
934        });
935        rt.normalize();
936        let d = diff("hello", "hXello");
937        rt.apply_text_delta(&d).unwrap();
938        let strong = rt
939            .marks
940            .iter()
941            .find(|m| matches!(m.kind, MarkKind::Strong))
942            .unwrap();
943        assert_eq!((strong.start, strong.end), (2, 5));
944        assert_eq!(rt.text, "hXello");
945    }
946
947    #[test]
948    fn apply_text_delta_pads_short_prepend() {
949        // A bare prepend names only its inserted text (no trailing retain); it
950        // still splices against the whole content rather than failing the base
951        // check (regression for the per-field delta path).
952        let mut rt = from_markdown("hello").unwrap();
953        rt.apply_text_delta(&Delta {
954            ops: vec![Op::Insert("NEW ".into())],
955        })
956        .unwrap();
957        assert_eq!(rt.text, "NEW hello");
958    }
959
960    #[test]
961    fn apply_text_delta_rejects_over_long_delta() {
962        // Consuming more base than exists is a wrong-revision delta, not an
963        // abbreviated one — it still fails closed.
964        let mut rt = from_markdown("hi").unwrap();
965        assert!(matches!(
966            rt.apply_text_delta(&Delta {
967                ops: vec![Op::Retain(99)],
968            }),
969            Err(ApplyError::DeltaBaseMismatch { .. })
970        ));
971        assert_eq!(rt.text, "hi");
972    }
973
974    #[test]
975    fn apply_mark_ops_add_and_remove() {
976        let mut rt = from_markdown("abcd").unwrap();
977        rt.apply_mark_ops(&[MarkOp::Add {
978            start: 0,
979            end: 2,
980            kind: MarkKind::Emph,
981        }])
982        .unwrap();
983        assert!(rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
984        rt.apply_mark_ops(&[MarkOp::Remove {
985            start: 0,
986            end: 4,
987            kind: MarkKind::Emph,
988        }])
989        .unwrap();
990        assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
991    }
992
993    #[test]
994    fn apply_mark_ops_remove_punches_hole() {
995        // Un-formatting the middle of a run leaves the two non-overlapping
996        // fragments, not an empty mark set (issue #901). Strong[0,6) over
997        // "abcdef", Remove[2,4) -> Strong[0,2) + Strong[4,6).
998        let mut rt = from_markdown("abcdef").unwrap();
999        rt.apply_mark_ops(&[MarkOp::Add {
1000            start: 0,
1001            end: 6,
1002            kind: MarkKind::Strong,
1003        }])
1004        .unwrap();
1005        rt.apply_mark_ops(&[MarkOp::Remove {
1006            start: 2,
1007            end: 4,
1008            kind: MarkKind::Strong,
1009        }])
1010        .unwrap();
1011        let strong: Vec<_> = rt
1012            .marks
1013            .iter()
1014            .filter(|m| matches!(m.kind, MarkKind::Strong))
1015            .map(|m| (m.start, m.end))
1016            .collect();
1017        assert_eq!(strong, vec![(0, 2), (4, 6)]);
1018    }
1019
1020    #[test]
1021    fn apply_mark_ops_remove_at_edge_leaves_no_zero_width() {
1022        // A removal flush against the mark's start yields a zero-width left
1023        // fragment [0,0); normalize drops it, leaving only the right fragment.
1024        let mut rt = from_markdown("abcdef").unwrap();
1025        rt.apply_mark_ops(&[MarkOp::Add {
1026            start: 0,
1027            end: 6,
1028            kind: MarkKind::Strong,
1029        }])
1030        .unwrap();
1031        rt.apply_mark_ops(&[MarkOp::Remove {
1032            start: 0,
1033            end: 2,
1034            kind: MarkKind::Strong,
1035        }])
1036        .unwrap();
1037        let strong: Vec<_> = rt
1038            .marks
1039            .iter()
1040            .filter(|m| matches!(m.kind, MarkKind::Strong))
1041            .map(|m| (m.start, m.end))
1042            .collect();
1043        assert_eq!(strong, vec![(2, 6)]);
1044    }
1045
1046    #[test]
1047    fn apply_mark_ops_remove_covering_range_drops_mark() {
1048        // A removal that fully covers the mark leaves nothing (both fragments
1049        // zero-width or inverted) — the whole-drop case still holds.
1050        let mut rt = from_markdown("abcdef").unwrap();
1051        rt.apply_mark_ops(&[MarkOp::Add {
1052            start: 2,
1053            end: 4,
1054            kind: MarkKind::Emph,
1055        }])
1056        .unwrap();
1057        rt.apply_mark_ops(&[MarkOp::Remove {
1058            start: 0,
1059            end: 6,
1060            kind: MarkKind::Emph,
1061        }])
1062        .unwrap();
1063        assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
1064    }
1065
1066    #[test]
1067    fn apply_mark_ops_remove_non_formatting_drops_whole() {
1068        // Identity/unknown handles can't be range-fragmented: an overlapping
1069        // one is dropped whole, never split into fragments.
1070        let mut rt = from_markdown("abcdef").unwrap();
1071        rt.marks.push(Mark {
1072            start: 0,
1073            end: 6,
1074            kind: MarkKind::Unknown {
1075                tag: "x".into(),
1076                attrs: serde_json::json!({}),
1077            },
1078        });
1079        rt.normalize();
1080        rt.apply_mark_ops(&[MarkOp::Remove {
1081            start: 2,
1082            end: 4,
1083            kind: MarkKind::Unknown {
1084                tag: "x".into(),
1085                attrs: serde_json::json!({}),
1086            },
1087        }])
1088        .unwrap();
1089        assert!(!rt
1090            .marks
1091            .iter()
1092            .any(|m| matches!(m.kind, MarkKind::Unknown { .. })));
1093    }
1094
1095    #[test]
1096    fn apply_text_delta_splits_lines_on_newline_insert() {
1097        let mut rt = from_markdown("one two").unwrap();
1098        let d = diff("one two", "one\ntwo");
1099        rt.apply_text_delta(&d).unwrap();
1100        assert_eq!(rt.lines.len(), 2);
1101        assert_eq!(rt.segment_count(), 2);
1102        assert_eq!(rt.validate(), Ok(()));
1103    }
1104
1105    #[test]
1106    fn line_op_split_and_join() {
1107        let mut rt = from_markdown("onetwo").unwrap();
1108        rt.apply_line_ops(&[LineOp::Split { at: 3 }]).unwrap();
1109        assert_eq!(rt.text, "one\ntwo");
1110        assert_eq!(rt.lines.len(), 2);
1111
1112        rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
1113        assert_eq!(rt.text, "onetwo");
1114        assert_eq!(rt.lines.len(), 1);
1115        assert_eq!(rt.validate(), Ok(()));
1116    }
1117
1118    #[test]
1119    fn line_op_set_kind() {
1120        let mut rt = from_markdown("title").unwrap();
1121        rt.apply_line_ops(&[LineOp::SetKind {
1122            line: 0,
1123            kind: LineKind::Heading { level: 2 },
1124        }])
1125        .unwrap();
1126        assert!(matches!(rt.lines[0].kind, LineKind::Heading { level: 2 }));
1127    }
1128
1129    #[test]
1130    fn line_op_set_continues_sets_and_clears() {
1131        // Two paragraph lines (delta-split → both `continues: false`, i.e. two
1132        // blocks). `setContinues` on line 1 turns the boundary into a within-block
1133        // hard break, and export then emits one block, not two paragraphs.
1134        let mut rt = from_markdown("one two").unwrap();
1135        rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
1136        assert!(!rt.lines[1].continues, "delta-split newline is a new block");
1137
1138        rt.apply_line_ops(&[LineOp::SetContinues {
1139            line: 1,
1140            continues: true,
1141        }])
1142        .unwrap();
1143        assert!(rt.lines[1].continues);
1144        assert_eq!(rt.validate(), Ok(()));
1145        assert_eq!(
1146            crate::export::to_markdown(&rt).matches("\n\n").count(),
1147            0,
1148            "a within-block hard break is not a paragraph boundary"
1149        );
1150
1151        // Clearing restores the block boundary.
1152        rt.apply_line_ops(&[LineOp::SetContinues {
1153            line: 1,
1154            continues: false,
1155        }])
1156        .unwrap();
1157        assert!(!rt.lines[1].continues);
1158        assert_eq!(rt.validate(), Ok(()));
1159    }
1160
1161    #[test]
1162    fn line_op_set_continues_rejects_first_line() {
1163        let mut rt = from_markdown("one two").unwrap();
1164        rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
1165        let before = rt.clone();
1166        // `continues: true` on line 0 forges `FirstLineContinues`; refused, and
1167        // the content is left untouched.
1168        assert_eq!(
1169            rt.apply_line_ops(&[LineOp::SetContinues {
1170                line: 0,
1171                continues: true,
1172            }]),
1173            Err(ApplyError::FirstLineContinues)
1174        );
1175        assert_eq!(rt, before, "rejected op leaves the content untouched");
1176        // Clearing line 0 (already `false`) is a no-op, not an error.
1177        rt.apply_line_ops(&[LineOp::SetContinues {
1178            line: 0,
1179            continues: false,
1180        }])
1181        .unwrap();
1182        assert_eq!(rt.validate(), Ok(()));
1183    }
1184
1185    fn island(id: &str) -> Island {
1186        Island {
1187            id: id.into(),
1188            island_type: "image".into(),
1189            props: serde_json::json!({}),
1190            loss: crate::model::Loss::Lossless,
1191        }
1192    }
1193
1194    /// A single-line content `ab` (one inline island slot, one backing island).
1195    fn content_with_island() -> Content {
1196        let mut rt = Content::empty();
1197        rt.text = format!("a{ISLAND_SLOT}b");
1198        rt.lines = vec![Line {
1199            kind: LineKind::Para,
1200            containers: vec![],
1201            continues: false,
1202        }];
1203        rt.islands = vec![island("i1")];
1204        assert_eq!(rt.validate(), Ok(()));
1205        rt
1206    }
1207
1208    #[test]
1209    fn delete_slot_cascades_island_removal() {
1210        let mut rt = content_with_island();
1211        // Delete the slot char at index 1 (`ab` -> `ab`).
1212        let d = Delta {
1213            ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(1)],
1214        };
1215        rt.apply_text_delta(&d).unwrap();
1216        assert_eq!(rt.text, "ab");
1217        assert!(rt.islands.is_empty(), "island cascaded away with its slot");
1218        // slot count now equals islands.len() — validate confirms the sync.
1219        assert_eq!(rt.validate(), Ok(()));
1220    }
1221
1222    #[test]
1223    fn delete_one_of_two_slots_removes_the_matching_island() {
1224        let mut rt = Content::empty();
1225        rt.text = format!("{ISLAND_SLOT}x{ISLAND_SLOT}");
1226        rt.lines = vec![Line {
1227            kind: LineKind::Para,
1228            containers: vec![],
1229            continues: false,
1230        }];
1231        rt.islands = vec![island("first"), island("second")];
1232        assert_eq!(rt.validate(), Ok(()));
1233
1234        // Delete the FIRST slot (index 0): `x` -> `x`.
1235        let d = Delta {
1236            ops: vec![Op::Delete(1), Op::Retain(2)],
1237        };
1238        rt.apply_text_delta(&d).unwrap();
1239        assert_eq!(rt.text, format!("x{ISLAND_SLOT}"));
1240        // The surviving island is the second one — the cascade removed the
1241        // island whose slot was deleted, not merely the last entry.
1242        assert_eq!(rt.islands.len(), 1);
1243        assert_eq!(rt.islands[0].id, "second");
1244        assert_eq!(rt.validate(), Ok(()));
1245    }
1246
1247    #[test]
1248    fn insert_raw_slot_is_rejected() {
1249        let mut rt = from_markdown("ab").unwrap();
1250        // An Op::Insert carrying a raw U+FFFC would orphan a slot — reject it.
1251        let d = Delta {
1252            ops: vec![
1253                Op::Retain(1),
1254                Op::Insert(ISLAND_SLOT.to_string()),
1255                Op::Retain(1),
1256            ],
1257        };
1258        assert_eq!(rt.apply_text_delta(&d), Err(ApplyError::IslandSlotInInsert));
1259        // Content untouched on the rejected insert (checked before any mutation).
1260        assert_eq!(rt.text, "ab");
1261        assert!(rt.islands.is_empty());
1262        assert_eq!(rt.validate(), Ok(()));
1263    }
1264
1265    #[test]
1266    fn insert_carriage_return_is_stripped() {
1267        // A `\r` in an insert is dropped, not persisted — the content stays
1268        // valid instead of the op returning Ok over a `CarriageReturn`
1269        // violation (issue #899). `\r\n` still yields the line-boundary `\n`.
1270        let mut rt = from_markdown("ab").unwrap();
1271        let d = Delta {
1272            ops: vec![Op::Retain(1), Op::Insert("\r".into()), Op::Retain(1)],
1273        };
1274        rt.apply_text_delta(&d).unwrap();
1275        assert_eq!(rt.text, "ab");
1276        assert_eq!(rt.validate(), Ok(()));
1277    }
1278
1279    #[test]
1280    fn insert_bidi_control_is_stripped() {
1281        // A bidi override (U+202E) in an insert is dropped — the content stays
1282        // valid and import's Trojan-source defense is not bypassed (issue #899).
1283        let mut rt = from_markdown("ab").unwrap();
1284        let d = Delta {
1285            ops: vec![
1286                Op::Retain(1),
1287                Op::Insert("\u{202E}".into()),
1288                Op::Retain(1),
1289            ],
1290        };
1291        rt.apply_text_delta(&d).unwrap();
1292        assert_eq!(rt.text, "ab");
1293        assert_eq!(rt.validate(), Ok(()));
1294    }
1295
1296    #[test]
1297    fn insert_crlf_keeps_the_newline_and_splits() {
1298        // Stripping only the `\r` of a `\r\n` leaves a real line boundary: the
1299        // insert still splits the line, and slot/line sync stays intact.
1300        let mut rt = from_markdown("ab").unwrap();
1301        let d = Delta {
1302            ops: vec![Op::Retain(1), Op::Insert("\r\n".into()), Op::Retain(1)],
1303        };
1304        rt.apply_text_delta(&d).unwrap();
1305        assert_eq!(rt.text, "a\nb");
1306        assert_eq!(rt.lines.len(), 2);
1307        assert_eq!(rt.validate(), Ok(()));
1308    }
1309
1310    #[test]
1311    fn insert_of_clean_text_is_not_reallocated() {
1312        // The hot path: a delta whose inserts carry no forbidden char borrows
1313        // through `sanitize_inserts` unchanged.
1314        let d = Delta {
1315            ops: vec![Op::Retain(1), Op::Insert("clean\n".into()), Op::Retain(1)],
1316        };
1317        assert!(matches!(sanitize_inserts(&d), Cow::Borrowed(_)));
1318    }
1319
1320    #[test]
1321    fn apply_field_change_bundle_order() {
1322        let mut rt = from_markdown("abc").unwrap();
1323        let d = diff("abc", "abXc");
1324        rt.apply_field_change(
1325            &d,
1326            &[],
1327            &[MarkOp::Add {
1328                start: 3,
1329                end: 4,
1330                kind: MarkKind::Strong,
1331            }],
1332        )
1333        .unwrap();
1334        let strong = rt
1335            .marks
1336            .iter()
1337            .find(|m| matches!(m.kind, MarkKind::Strong))
1338            .unwrap();
1339        assert_eq!((strong.start, strong.end), (3, 4));
1340        assert_eq!(rt.text, "abXc");
1341    }
1342
1343    #[test]
1344    fn apply_field_change_is_all_or_nothing() {
1345        // A bundle whose text delta and first mark op succeed but whose second
1346        // mark op is out of range must leave the content exactly as it was — the
1347        // successful earlier stages do not partially commit.
1348        let mut rt = from_markdown("abc").unwrap();
1349        let before = rt.clone();
1350        let d = diff("abc", "abXc");
1351        let err = rt.apply_field_change(
1352            &d,
1353            &[],
1354            &[
1355                MarkOp::Add {
1356                    start: 0,
1357                    end: 2,
1358                    kind: MarkKind::Strong,
1359                },
1360                MarkOp::Add {
1361                    start: 99,
1362                    end: 100,
1363                    kind: MarkKind::Emph,
1364                },
1365            ],
1366        );
1367        assert!(matches!(err, Err(ApplyError::MarkOutOfRange { .. })));
1368        assert_eq!(rt, before, "failed bundle must not mutate the content");
1369    }
1370
1371    // ── sync_lines_for_delta characterization (issue #926 finding 2) ─────────
1372    //
1373    // Pin the observable behavior of the line-sync walk — retain/insert/delete
1374    // interleavings, the split template-clone rule, and the malformed-content
1375    // guards — against a silent change to its internals.
1376
1377    /// A `Heading{level}` line, its level a visible tag so a test can trace
1378    /// which original line landed where; `continues` distinguishes a clone.
1379    fn tag_line(level: u8, continues: bool) -> Line {
1380        Line {
1381            kind: LineKind::Heading { level },
1382            containers: Vec::new(),
1383            continues,
1384        }
1385    }
1386
1387    /// `(tag, continues)` per line — `Heading{level}` reads its level, `Para` is
1388    /// tag 0 (the default line), any other kind is 255.
1389    fn tags(lines: &[Line]) -> Vec<(u8, bool)> {
1390        lines
1391            .iter()
1392            .map(|l| match l.kind {
1393                LineKind::Heading { level } => (level, l.continues),
1394                LineKind::Para => (0, l.continues),
1395                _ => (255, l.continues),
1396            })
1397            .collect()
1398    }
1399
1400    #[test]
1401    fn sync_lines_retain_only_is_identity() {
1402        let old_chars: Vec<char> = "a\nb\nc".chars().collect();
1403        let lines = vec![tag_line(1, false), tag_line(2, false), tag_line(3, false)];
1404        let d = Delta {
1405            ops: vec![Op::Retain(5)],
1406        };
1407        assert_eq!(sync_lines_for_delta(&old_chars, lines.clone(), &d), lines);
1408    }
1409
1410    #[test]
1411    fn sync_lines_insert_newline_clones_split_line_and_clears_continues() {
1412        // Split line 1 ("bc") mid-line: the first half stays the original line
1413        // (keeps kind, containers, and its `continues: true`); the second half
1414        // is a clone of it with `continues` forced false.
1415        let old_chars: Vec<char> = "a\nbc".chars().collect();
1416        let l1 = Line {
1417            kind: LineKind::Heading { level: 5 },
1418            containers: vec![Container::Quote],
1419            continues: true,
1420        };
1421        let lines = vec![tag_line(1, false), l1.clone()];
1422        // Retain(3)[a\nb] moves to line 1; Insert("\n") splits it; Retain(1)[c].
1423        let d = Delta {
1424            ops: vec![Op::Retain(3), Op::Insert("\n".into()), Op::Retain(1)],
1425        };
1426        let out = sync_lines_for_delta(&old_chars, lines, &d);
1427        assert_eq!(out.len(), 3);
1428        assert_eq!(out[1], l1, "first half is the untouched original line");
1429        assert_eq!(out[2].kind, LineKind::Heading { level: 5 });
1430        assert_eq!(out[2].containers, vec![Container::Quote]);
1431        assert!(!out[2].continues, "the split clone starts a new block");
1432    }
1433
1434    #[test]
1435    fn sync_lines_delete_newline_drops_following_line() {
1436        // Delete the first '\n' of "a\nb\nc": lines 0 and 1 merge, dropping line
1437        // 1; the current line (0) and line 2 survive.
1438        let old_chars: Vec<char> = "a\nb\nc".chars().collect();
1439        let lines = vec![tag_line(1, false), tag_line(2, false), tag_line(3, false)];
1440        let d = Delta {
1441            ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(3)],
1442        };
1443        let out = sync_lines_for_delta(&old_chars, lines, &d);
1444        assert_eq!(tags(&out), vec![(1, false), (3, false)]);
1445    }
1446
1447    #[test]
1448    fn sync_lines_delete_trailing_newline_without_following_line_is_guarded() {
1449        // Malformed content: text "a\n" is two segments but `lines` has one
1450        // entry. Deleting the '\n' when `line_idx + 1` is out of bounds removes
1451        // nothing (the guard), leaving the single line intact.
1452        let old_chars: Vec<char> = "a\n".chars().collect();
1453        let lines = vec![tag_line(1, false)];
1454        let d = Delta {
1455            ops: vec![Op::Retain(1), Op::Delete(1)],
1456        };
1457        let out = sync_lines_for_delta(&old_chars, lines, &d);
1458        assert_eq!(tags(&out), vec![(1, false)]);
1459    }
1460
1461    #[test]
1462    fn sync_lines_stops_at_end_of_old_chars() {
1463        // A retain running past the end of old_chars stops at the end rather
1464        // than indexing out of bounds (the `old >= old_chars.len()` guard).
1465        let old_chars: Vec<char> = "a\nb".chars().collect();
1466        let lines = vec![tag_line(1, false), tag_line(2, false)];
1467        let d = Delta {
1468            ops: vec![Op::Retain(99)],
1469        };
1470        assert_eq!(sync_lines_for_delta(&old_chars, lines.clone(), &d), lines);
1471    }
1472
1473    #[test]
1474    fn sync_lines_insert_two_newlines_adds_two_clones() {
1475        // Inserting "\n\n" mid-line adds two lines, each carrying the split
1476        // line's kind and containers with `continues: false`.
1477        let old_chars: Vec<char> = "abc".chars().collect();
1478        let src = Line {
1479            kind: LineKind::Heading { level: 7 },
1480            containers: vec![Container::Quote],
1481            continues: false,
1482        };
1483        let d = Delta {
1484            ops: vec![Op::Retain(1), Op::Insert("\n\n".into()), Op::Retain(2)],
1485        };
1486        let out = sync_lines_for_delta(&old_chars, vec![src], &d);
1487        assert_eq!(out.len(), 3);
1488        for l in &out {
1489            assert_eq!(l.kind, LineKind::Heading { level: 7 });
1490            assert_eq!(l.containers, vec![Container::Quote]);
1491            assert!(!l.continues);
1492        }
1493    }
1494
1495    // ── line-op mark remap + terminal-normalize collapse (issue #926 finding 3) ──
1496
1497    #[test]
1498    fn split_line_rebases_mark_across_the_split_point() {
1499        // A strong mark spanning the split point grows by the inserted `\n`
1500        // rather than staying at its old coordinates. "abcd", strong[1..3)
1501        // ("bc"); split at 2 → "ab\ncd"; the mark must still cover "b"+"c",
1502        // i.e. [1..4) over "ab\ncd".
1503        let mut rt = from_markdown("abcd").unwrap();
1504        rt.apply_mark_ops(&[MarkOp::Add {
1505            start: 1,
1506            end: 3,
1507            kind: MarkKind::Strong,
1508        }])
1509        .unwrap();
1510        rt.apply_line_ops(&[LineOp::Split { at: 2 }]).unwrap();
1511        assert_eq!(rt.text, "ab\ncd");
1512        let strong: Vec<_> = rt
1513            .marks
1514            .iter()
1515            .filter(|m| matches!(m.kind, MarkKind::Strong))
1516            .map(|m| (m.start, m.end))
1517            .collect();
1518        // [1..4) spans "b\nc"; normalize keeps the interior `\n` (a mark may
1519        // legitimately span lines), trimming only leading/trailing boundaries.
1520        assert_eq!(strong, vec![(1, 4)]);
1521        assert_eq!(rt.validate(), Ok(()));
1522    }
1523
1524    #[test]
1525    fn join_line_rebases_marks_to_final_text_coordinates() {
1526        // The issue's concrete drift case: "ab\ncd", strong[2..4) (over "\nc").
1527        // Joining line 0 removes the `\n`; the remap + terminal normalize must
1528        // land strong on "c" — coordinate [2..3) over "abcd" — not on "d"
1529        // (the un-remapped-mark bug) nor on "cd".
1530        let mut rt = from_markdown("ab").unwrap();
1531        rt.apply_text_delta(&diff("ab", "ab\ncd")).unwrap();
1532        rt.marks.push(Mark {
1533            start: 2,
1534            end: 4,
1535            kind: MarkKind::Strong,
1536        });
1537        rt.normalize();
1538        // Post-normalize the `\n` edge trims to [3..4) ("c"); either way the
1539        // join must converge to strong on "c".
1540        rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
1541        assert_eq!(rt.text, "abcd");
1542        let strong: Vec<_> = rt
1543            .marks
1544            .iter()
1545            .filter(|m| matches!(m.kind, MarkKind::Strong))
1546            .map(|m| (m.start, m.end))
1547            .collect();
1548        assert_eq!(strong, vec![(2, 3)], "strong lands on 'c', not 'd' or 'cd'");
1549        assert_eq!(rt.validate(), Ok(()));
1550    }
1551
1552    #[test]
1553    fn field_change_terminal_normalize_matches_per_stage_normalize() {
1554        // The collapse proof obligation: a bundle applied through
1555        // `apply_field_change` (one terminal normalize) must equal applying the
1556        // same stages each with its own normalize (the public wrappers). The
1557        // remap through split/join is what makes the two converge.
1558        let start = from_markdown("hello world").unwrap();
1559        let text_delta = diff("hello world", "hello brave world");
1560        let line_ops = vec![LineOp::Split { at: 5 }]; // after "hello"
1561        let mark_ops = vec![MarkOp::Add {
1562            start: 0,
1563            end: 5,
1564            kind: MarkKind::Strong,
1565        }];
1566
1567        let mut bundled = start.clone();
1568        bundled
1569            .apply_field_change(&text_delta, &line_ops, &mark_ops)
1570            .unwrap();
1571
1572        let mut staged = start;
1573        staged.apply_text_delta(&text_delta).unwrap();
1574        staged.apply_line_ops(&line_ops).unwrap();
1575        staged.apply_mark_ops(&mark_ops).unwrap();
1576
1577        assert_eq!(bundled, staged, "terminal normalize diverged from per-stage");
1578        assert_eq!(bundled.validate(), Ok(()));
1579    }
1580
1581    #[test]
1582    fn sync_lines_select_all_delete_collapses_to_first_line() {
1583        // The motivating case (issue #926 finding 2): deleting a whole
1584        // multi-line body drops every line but the first (each deleted '\n'
1585        // merges the next line away).
1586        let text: String = (0..50).map(|i| format!("line{i}\n")).collect();
1587        let old_chars: Vec<char> = text.chars().collect();
1588        let lines: Vec<Line> = (0..=50).map(|i| tag_line((i % 200) as u8, false)).collect();
1589        assert_eq!(lines.len(), old_chars.iter().filter(|&&c| c == '\n').count() + 1);
1590        let d = Delta {
1591            ops: vec![Op::Delete(old_chars.len())],
1592        };
1593        let out = sync_lines_for_delta(&old_chars, lines, &d);
1594        assert_eq!(tags(&out), vec![(0, false)], "only the first line survives");
1595    }
1596
1597    #[test]
1598    fn sync_lines_insert_newline_past_end_appends_default() {
1599        // Malformed content: after a retain walks past the sole line (line_idx ==
1600        // lines.len()), an inserted '\n' has no line to clone and appends a
1601        // default Para.
1602        let old_chars: Vec<char> = "a\n".chars().collect();
1603        let lines = vec![tag_line(1, false)];
1604        // Retain(2)[a\n] moves line_idx to 1 (== lines.len()); Insert("\n").
1605        let d = Delta {
1606            ops: vec![Op::Retain(2), Op::Insert("\n".into())],
1607        };
1608        let out = sync_lines_for_delta(&old_chars, lines, &d);
1609        assert_eq!(out.len(), 2);
1610        assert_eq!(tags(&out)[0], (1, false));
1611        assert_eq!(out[1].kind, LineKind::Para);
1612        assert!(out[1].containers.is_empty());
1613        assert!(!out[1].continues);
1614    }
1615}