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