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