Skip to main content

quillmark_content/
ops.rs

1//! Island, line and mark op channels: structural edits separate from text
2//! splices.
3//!
4//! [`IslandOp`], [`LineOp`] and [`MarkOp`] apply after
5//! [`Content::apply_text_delta`] in one [`ChangeBundle`], in that order. Each
6//! channel reaches a part of the model no splice can: an island's payload, a
7//! line's block role, a mark's range. Mark ranges are in **final-text
8//! coordinates**: mark ops run last and validate against the length every
9//! earlier stage left, so a producer
10//! computes them in the only frame it can, the text as it stands once the
11//! delta and line ops have landed. Line split/join splice a `\n` in `text` and
12//! rebase marks through that one-char change with
13//! [`Delta::map_pos`](crate::delta::Delta::map_pos), the same mapping the
14//! text-delta channel uses, so a mark's coordinates track the splice rather
15//! than drifting.
16
17use crate::delta::{Assoc, Delta, Op};
18use crate::model::{
19    line_kind_mismatch, Container, Island, Line, LineKind, LineKindMismatch, Mark, MarkKind,
20    Content, Usv, ISLAND_SLOT,
21};
22use crate::normalize::is_bidi_char;
23use crate::usv::char_to_byte;
24use std::borrow::Cow;
25
26/// A mark edit in final-text coordinates (post-delta, post-line-op).
27#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub enum MarkOp {
30    /// Add a mark over `[start, end)`. An anchor `kind` must carry a non-empty
31    /// `id` not already live in the field: ids are caller-supplied and unique
32    /// per `Content` (`DOCUMENT_STORAGE.md` § Anchor-id identity); a collision or
33    /// the empty id is rejected ([`ApplyError::AnchorIdCollision`] /
34    /// [`ApplyError::EmptyAnchorId`]), never replaced or coexisted.
35    Add {
36        start: Usv,
37        end: Usv,
38        kind: MarkKind,
39    },
40    /// Un-format `kind` over `[start, end)`: subtract the range from each
41    /// overlapping same-kind *formatting* mark, keeping the non-overlapping
42    /// fragments (a mid-run removal punches a hole; `normalize` drops any
43    /// zero-width fragment an edge-aligned removal leaves). Non-formatting
44    /// (identity/unknown) handles can't be range-fragmented, so an overlapping
45    /// one is dropped whole: anchors normally go through [`MarkOp::RemoveAnchor`].
46    Remove {
47        start: Usv,
48        end: Usv,
49        kind: MarkKind,
50    },
51    /// Drop one identity anchor by id.
52    RemoveAnchor { id: String },
53}
54
55/// A line/block edit. Split/join splice `\n` in `text`; set ops touch metadata
56/// only.
57#[derive(Debug, Clone, PartialEq)]
58#[non_exhaustive]
59pub enum LineOp {
60    /// Paragraph break at `at`: insert `\n` and split the line metadata.
61    Split { at: Usv },
62    /// Join line `line` with the next: remove the `\n` between them.
63    Join { line: usize },
64    /// Replace a line's block role.
65    SetKind { line: usize, kind: LineKind },
66    /// Replace a line's container path.
67    SetContainers {
68        line: usize,
69        containers: Vec<Container>,
70    },
71    /// Set (or clear) a line's `continues` flag: whether it continues the
72    /// previous line's block across a within-block hard break (a markdown hard
73    /// break, a code fence's interior line) rather than starting a new block.
74    /// The op-grained twin of the value that `install` already round-trips:
75    /// `split`/`join`/text-delta `\n` insertion all mint `continues: false`
76    /// lines, so without this a hard break or a new code-fence interior line is
77    /// unreachable op-wise and falls back to a whole-`install` (losing that
78    /// edit's identity anchors). Setting `continues: true` on line 0 is
79    /// [`ApplyError::FirstLineContinues`] (nothing precedes it to continue).
80    SetContinues { line: usize, continues: bool },
81}
82
83/// An island edit: the channel that reaches [`Island`] payloads, which no other
84/// channel carries (`text` holds one [`ISLAND_SLOT`] per island and nothing
85/// more, `lines` the [`LineKind::Island`] tag, `marks` neither).
86///
87/// Both ops are **value semantics over one island entry**, not over the field:
88/// the slot stays put, so every identity anchor in the field's text survives an
89/// island edit. Without them a table edit lowers to a whole-field `install`,
90/// which drops every anchor in the field: [`LineOp::SetContinues`]'s argument at
91/// the scale of a table.
92///
93/// Removal needs no op: a text delta that deletes a slot drops the backing
94/// entry ([`Content::apply_text_delta`]'s cascade). The drop is whole, so
95/// re-landing that island is an [`IslandOp::Insert`] carrying the [`Island`]
96/// itself, which only the producer that deleted it still holds. A *block*
97/// island's line demotes to `Para` when its slot goes, so re-landing one
98/// re-tags the line too.
99#[derive(Debug, Clone, PartialEq)]
100#[non_exhaustive]
101pub enum IslandOp {
102    /// Replace the entry `island.id` names, in place. The id is the target *and*
103    /// the stored value, so an island cannot be renamed through this op: ids are
104    /// hash input and stable across edits by contract
105    /// (`DOCUMENT_STORAGE.md` § Island-id determinism). An id no island carries
106    /// is [`ApplyError::UnknownIslandId`], never a silent no-op: swallowing it
107    /// leaves the store on the old value with the caller believing it committed.
108    ///
109    /// `props`, `island_type` and `loss` all come from the op. Nothing derives
110    /// `loss` from the props: like `install`, the op stores what the caller hands
111    /// it, so a write that changes what markdown can carry restates the class or
112    /// carries the stale one forward.
113    Set { island: Island },
114    /// Insert an island: the [`ISLAND_SLOT`] at `at` and its backing entry in
115    /// one op, so a slot never exists without the [`Island`] behind it (the
116    /// orphan [`ApplyError::IslandSlotInInsert`] guards against on the text
117    /// channel is unrepresentable here rather than rejected after the fact).
118    ///
119    /// `at` is a USV position in the text the delta and this bundle's earlier
120    /// island ops left: each insert splices its slot before the next op reads
121    /// the text. Slots after `a` and `b` of `abc` therefore go in at 1 and 3,
122    /// and of two inserts at one position the later one lands first. The entry
123    /// files at its slot-order index in that same frame, so text and island
124    /// list agree whatever the emission order; a stale frame misplaces slots
125    /// and never errors.
126    ///
127    /// The id is caller-supplied, non-empty, and unique in the field, on an
128    /// anchor id's terms ([`ApplyError::EmptyIslandId`],
129    /// [`ApplyError::IslandIdCollision`]). `Set` addresses by id, so a
130    /// degenerate or shared id is an island that cannot be edited, or cannot be
131    /// told from another. Minting follows `DOCUMENT_STORAGE.md` § Island-id
132    /// determinism: a new island continues the field's positional sequence,
133    /// while re-landing a dropped one carries its original id back. A delete
134    /// earlier in the same bundle frees its id here, which is how a
135    /// replace-in-place lands as one bundle.
136    ///
137    /// **Block islands.** The slot alone is an *inline* island (a slot in a
138    /// `Para`). A block island is that slot alone on its own line under
139    /// [`LineKind::Island`], which takes three channels in one bundle: the text
140    /// delta inserts the `\n`, this op inserts the slot, and
141    /// [`LineOp::SetKind`] tags the line. That order is why island ops run
142    /// *before* line ops: `SetKind` validates the kind against the text already
143    /// on the line, so the slot has to be there first. `LineOp::Split` cannot
144    /// stand in for the delta's `\n`: it runs in the later stage.
145    ///
146    /// A slot inserted onto a line whose kind names its content (`Code`, `Rule`)
147    /// contradicts that kind; `normalize` demotes the line to `Para` at the end
148    /// of the bundle rather than failing it.
149    Insert { at: Usv, island: Island },
150}
151
152/// One committed field edit: a text delta and the three op channels, applied in
153/// field order (delta → islands → lines → marks) by
154/// [`Content::apply_field_change`].
155///
156/// A struct rather than four positional arguments so a fifth channel is an
157/// additive change at every call site. [`Default`] is the identity bundle (no
158/// text change, no ops), so a caller names only the channels it uses:
159/// `ChangeBundle { delta, ..Default::default() }`.
160///
161/// Within a channel ops apply in sequence: op *n*'s coordinates read the state
162/// ops `0..n` left, not the frame the channel opens in. That is USV positions
163/// for an island insert (its `at` counts the slots earlier inserts spliced) and
164/// line indices for [`LineOp::Split`] / [`LineOp::Join`], which renumber every
165/// later line. A stale frame stays in range, so the bundle applies cleanly and
166/// lands the wrong document.
167#[derive(Debug, Clone, PartialEq)]
168pub struct ChangeBundle {
169    /// The text splice; the identity delta (no ops) is no text change.
170    pub delta: Delta,
171    /// Island edits, opening in post-delta coordinates and sequenced.
172    pub island_ops: Vec<IslandOp>,
173    /// Line edits, opening in post-delta, post-island-op coordinates and
174    /// sequenced.
175    pub line_ops: Vec<LineOp>,
176    /// Mark edits, in final-text coordinates (every earlier stage applied).
177    pub mark_ops: Vec<MarkOp>,
178}
179
180impl Default for ChangeBundle {
181    fn default() -> Self {
182        ChangeBundle {
183            delta: Delta { ops: Vec::new() },
184            island_ops: Vec::new(),
185            line_ops: Vec::new(),
186            mark_ops: Vec::new(),
187        }
188    }
189}
190
191impl ChangeBundle {
192    /// A bundle carrying `delta` and no ops: the per-keystroke splice.
193    pub fn from_delta(delta: Delta) -> Self {
194        ChangeBundle {
195            delta,
196            ..Default::default()
197        }
198    }
199
200    fn is_delta_only(&self) -> bool {
201        self.island_ops.is_empty() && self.line_ops.is_empty() && self.mark_ops.is_empty()
202    }
203}
204
205// ── Change-bundle wire (mark / line op ⇄ JSON) ──────────────────────────────
206//
207// [`Delta`] serializes through serde derive; [`MarkOp`] and [`LineOp`] carry
208// [`MarkKind`] / [`LineKind`] / [`Container`], whose canonical JSON is the
209// hand-written `serial` encoding (the `{type, …}` / `{kind, …}` discriminants a
210// `ContentMark` / `ContentLine` already uses). These converters reuse that
211// exact vocabulary so the `applyChange` bundle speaks the same shapes the
212// content read surface does, rather than a second serde-derived dialect. The
213// language bindings call them to lower a JS/Python bundle to core ops.
214
215use crate::serial::{
216    container_from_authored_value, container_to_value, island_from_value, island_to_value,
217    line_kind_from_authored_value, line_kind_to_value, mark_from_authored_value, mark_to_value,
218    usv_from, ParseError,
219};
220use serde_json::{Map, Value};
221
222/// Encode a [`MarkOp`] to its wire object. `Add`/`Remove` carry the mark
223/// vocabulary (`{op, start, end, type, …}`); `RemoveAnchor` is `{op, id}`.
224pub fn mark_op_to_value(op: &MarkOp) -> Value {
225    let mut m = Map::new();
226    match op {
227        MarkOp::Add { start, end, kind } => {
228            m.insert("op".into(), "add".into());
229            merge_mark(&mut m, *start, *end, kind);
230        }
231        MarkOp::Remove { start, end, kind } => {
232            m.insert("op".into(), "remove".into());
233            merge_mark(&mut m, *start, *end, kind);
234        }
235        MarkOp::RemoveAnchor { id } => {
236            m.insert("op".into(), "removeAnchor".into());
237            m.insert("id".into(), Value::String(id.clone()));
238        }
239    }
240    Value::Object(m)
241}
242
243/// Merge a mark's `{start, end, type, …}` fields into an op object, reusing the
244/// canonical `serial` mark encoding.
245fn merge_mark(m: &mut Map<String, Value>, start: Usv, end: Usv, kind: &MarkKind) {
246    let mark = Mark {
247        start,
248        end,
249        kind: kind.clone(),
250    };
251    if let Value::Object(fields) = mark_to_value(&mark) {
252        m.extend(fields);
253    }
254}
255
256/// Decode a [`MarkOp`] from its wire object. Dispatches on `op`; `add`/`remove`
257/// read the mark vocabulary on the authored lane, which refuses `attrs` beside a
258/// built-in `type` rather than resolving to the built-in and dropping them.
259pub fn mark_op_from_value(v: &Value) -> Result<MarkOp, ParseError> {
260    let o = v.as_object().ok_or(ParseError::Shape("mark op"))?;
261    match o.get("op").and_then(Value::as_str) {
262        Some("add") => {
263            let mark = mark_from_authored_value(v)?;
264            Ok(MarkOp::Add {
265                start: mark.start,
266                end: mark.end,
267                kind: mark.kind,
268            })
269        }
270        Some("remove") => {
271            let mark = mark_from_authored_value(v)?;
272            Ok(MarkOp::Remove {
273                start: mark.start,
274                end: mark.end,
275                kind: mark.kind,
276            })
277        }
278        Some("removeAnchor") => Ok(MarkOp::RemoveAnchor {
279            id: o
280                .get("id")
281                .and_then(Value::as_str)
282                .ok_or(ParseError::Shape("removeAnchor id"))?
283                .to_string(),
284        }),
285        _ => Err(ParseError::Shape("mark op kind")),
286    }
287}
288
289/// Encode a [`LineOp`] to its wire object. `SetKind` flattens the line-kind
290/// discriminant (`kind`/`level`/`lang`) alongside `op`/`line`.
291pub fn line_op_to_value(op: &LineOp) -> Value {
292    let mut m = Map::new();
293    match op {
294        LineOp::Split { at } => {
295            m.insert("op".into(), "split".into());
296            m.insert("at".into(), Value::from(*at));
297        }
298        LineOp::Join { line } => {
299            m.insert("op".into(), "join".into());
300            m.insert("line".into(), Value::from(*line));
301        }
302        LineOp::SetKind { line, kind } => {
303            m.insert("op".into(), "setKind".into());
304            m.insert("line".into(), Value::from(*line));
305            if let Value::Object(fields) = line_kind_to_value(kind) {
306                m.extend(fields);
307            }
308        }
309        LineOp::SetContainers { line, containers } => {
310            m.insert("op".into(), "setContainers".into());
311            m.insert("line".into(), Value::from(*line));
312            m.insert(
313                "containers".into(),
314                Value::Array(containers.iter().map(container_to_value).collect()),
315            );
316        }
317        LineOp::SetContinues { line, continues } => {
318            m.insert("op".into(), "setContinues".into());
319            m.insert("line".into(), Value::from(*line));
320            m.insert("continues".into(), Value::Bool(*continues));
321        }
322    }
323    Value::Object(m)
324}
325
326/// Decode a [`LineOp`] from its wire object. Dispatches on `op`.
327pub fn line_op_from_value(v: &Value) -> Result<LineOp, ParseError> {
328    let o = v.as_object().ok_or(ParseError::Shape("line op"))?;
329    let line = || usv_from(o.get("line"), "line op line");
330    match o.get("op").and_then(Value::as_str) {
331        Some("split") => Ok(LineOp::Split {
332            at: usv_from(o.get("at"), "split at")?,
333        }),
334        Some("join") => Ok(LineOp::Join { line: line()? }),
335        Some("setKind") => Ok(LineOp::SetKind {
336            line: line()?,
337            kind: line_kind_from_authored_value(v)?,
338        }),
339        Some("setContainers") => Ok(LineOp::SetContainers {
340            line: line()?,
341            containers: o
342                .get("containers")
343                .and_then(Value::as_array)
344                .ok_or(ParseError::Shape("setContainers containers"))?
345                .iter()
346                .map(container_from_authored_value)
347                .collect::<Result<_, _>>()?,
348        }),
349        Some("setContinues") => Ok(LineOp::SetContinues {
350            line: line()?,
351            continues: o
352                .get("continues")
353                .and_then(Value::as_bool)
354                .ok_or(ParseError::Shape("setContinues continues"))?,
355        }),
356        _ => Err(ParseError::Shape("line op kind")),
357    }
358}
359
360/// Encode an [`IslandOp`] to its wire object. Both arms flatten the island
361/// vocabulary (`{id, type, props, loss}`) alongside `op`, as [`LineOp::SetKind`]
362/// flattens the line-kind discriminant.
363pub fn island_op_to_value(op: &IslandOp) -> Value {
364    let (verb, at, island) = match op {
365        IslandOp::Set { island } => ("set", None, island),
366        IslandOp::Insert { at, island } => ("insert", Some(*at), island),
367    };
368    let mut m = Map::new();
369    m.insert("op".into(), verb.into());
370    if let Some(at) = at {
371        m.insert("at".into(), Value::from(at));
372    }
373    if let Value::Object(fields) = island_to_value(island) {
374        m.extend(fields);
375    }
376    Value::Object(m)
377}
378
379/// Decode an [`IslandOp`] from its wire object. Dispatches on `op`; both arms
380/// read the island vocabulary, so an op carries the same `{id, type, props,
381/// loss}` shape a `ContentIsland` does.
382pub fn island_op_from_value(v: &Value) -> Result<IslandOp, ParseError> {
383    let o = v.as_object().ok_or(ParseError::Shape("island op"))?;
384    let island = || island_from_value(v);
385    match o.get("op").and_then(Value::as_str) {
386        Some("set") => Ok(IslandOp::Set { island: island()? }),
387        Some("insert") => Ok(IslandOp::Insert {
388            at: usv_from(o.get("at"), "island insert at")?,
389            island: island()?,
390        }),
391        _ => Err(ParseError::Shape("island op kind")),
392    }
393}
394
395/// Lower a committed change **bundle** object (`{delta?, islandOps?, lineOps?,
396/// markOps?}`) to core ops: the whole-bundle reader the `applyChange` verb needs,
397/// so each binding lowers a JS/Python bundle in one call instead of re-deriving
398/// the delta/op extraction. A missing `delta` is the identity (no text change); a
399/// missing/`null` op array is empty. Both camelCase (`lineOps`) and snake_case
400/// (`line_ops`) keys are accepted, so the one reader serves the wasm (camelCase)
401/// and Python (either) surfaces. The error is a message string the binding wraps
402/// in its own error type.
403pub fn change_bundle_from_value(v: &Value) -> Result<ChangeBundle, String> {
404    let obj = v
405        .as_object()
406        .ok_or("bundle must be an object { delta?, islandOps?, lineOps?, markOps? }")?;
407    let get = |snake: &str, camel: &str| obj.get(snake).or_else(|| obj.get(camel));
408    let delta = match get("delta", "delta") {
409        Some(Value::Null) | None => Delta { ops: Vec::new() },
410        Some(d) => serde_json::from_value(d.clone()).map_err(|e| format!("invalid delta: {e}"))?,
411    };
412    Ok(ChangeBundle {
413        delta,
414        island_ops: op_array(
415            get("island_ops", "islandOps"),
416            island_op_from_value,
417            "islandOps",
418        )?,
419        line_ops: op_array(get("line_ops", "lineOps"), line_op_from_value, "lineOps")?,
420        mark_ops: op_array(get("mark_ops", "markOps"), mark_op_from_value, "markOps")?,
421    })
422}
423
424/// Lower an optional JSON array of op objects through `convert` (missing/`null`
425/// → empty), naming `what` in any shape-error message. The list twin shared by
426/// [`change_bundle_from_value`]'s line- and mark-op channels.
427fn op_array<T>(
428    value: Option<&Value>,
429    convert: impl Fn(&Value) -> Result<T, ParseError>,
430    what: &str,
431) -> Result<Vec<T>, String> {
432    let Some(value) = value else {
433        return Ok(Vec::new());
434    };
435    if value.is_null() {
436        return Ok(Vec::new());
437    }
438    let arr = value
439        .as_array()
440        .ok_or_else(|| format!("{what} must be an array"))?;
441    arr.iter()
442        .map(|v| convert(v).map_err(|e| format!("invalid {what}: {e}")))
443        .collect()
444}
445
446/// Why an apply failed: range or line index out of bounds, or invariants
447/// broken before normalization could repair them.
448#[derive(Debug, Clone, PartialEq, Eq)]
449#[non_exhaustive]
450pub enum ApplyError {
451    MarkOutOfRange {
452        start: Usv,
453        end: Usv,
454        len: Usv,
455    },
456    LineOutOfRange {
457        line: usize,
458        lines: usize,
459    },
460    SplitPositionOutOfRange {
461        at: Usv,
462        len: Usv,
463    },
464    SplitAtNewline {
465        at: Usv,
466    },
467    LineCountMismatch {
468        lines: usize,
469        segments: usize,
470    },
471    /// A [`LineOp::SetContinues`] tried to set `continues: true` on line 0, which
472    /// has nothing before it to continue, the apply-time twin of the
473    /// [`Invariant::FirstLineContinues`](crate::model::Invariant::FirstLineContinues)
474    /// validation error, refused here because `normalize` does not repair it.
475    FirstLineContinues,
476    /// The text delta's expected base length disagreed with the content:
477    /// it was built against a different revision.
478    DeltaBaseMismatch {
479        expected: usize,
480        actual: usize,
481    },
482    /// An `Op::Insert` carried a raw [`ISLAND_SLOT`]. Islands are structurally
483    /// uneditable through the text channel: a slot inserted here would have no
484    /// backing [`Island`], an orphaned-slot invariant violation. Islands are
485    /// created through [`IslandOp::Insert`], which carries the slot and its
486    /// entry in one op, never a text splice.
487    ///
488    /// A producer that computes one splice over the whole field text carries
489    /// slots in it whenever the user pastes an island or undoes a deletion that
490    /// removed one. Such a splice splits: the delta with its slots stripped,
491    /// plus one [`IslandOp::Insert`] per slot at the frame that delta leaves.
492    IslandSlotInInsert,
493    /// A [`MarkOp::Add`] of an anchor whose `id` is already live in the field.
494    /// An anchor id is a caller-supplied handle, unique per `Content`
495    /// (`DOCUMENT_STORAGE.md` § Anchor-id identity); `add` rejects a collision
496    /// rather than replace (which would silently retarget a live thread) or
497    /// coexist (which `RemoveAnchor` cannot disambiguate). The op-time twin of
498    /// [`Invariant::AnchorIdCollision`](crate::model::Invariant::AnchorIdCollision).
499    AnchorIdCollision { id: String },
500    /// A [`MarkOp::Add`] of an anchor with the empty `id`: a degenerate handle,
501    /// refused so every anchor carries a usable referent.
502    EmptyAnchorId,
503    /// An [`IslandOp::Set`] naming an `id` no island in the field carries,
504    /// refused rather than ignored ([`IslandOp::Set`] states why).
505    UnknownIslandId { id: String },
506    /// An [`IslandOp::Insert`] whose `id` is already live in the field. Island
507    /// ids are unique per `Content` (the
508    /// [`Invariant::IslandIdCollision`](crate::model::Invariant::IslandIdCollision)
509    /// this is the op-time twin of); `Set` addresses by id, so a duplicate is an
510    /// island neither op can name unambiguously.
511    IslandIdCollision { id: String },
512    /// An [`IslandOp::Insert`] carrying the empty `id`: an island `Set` could
513    /// never address, refused on the same terms as [`Self::EmptyAnchorId`].
514    EmptyIslandId,
515    /// An [`IslandOp::Insert`] whose `at` is past the end of the text the delta
516    /// and this bundle's earlier island ops left.
517    IslandInsertOutOfRange { at: Usv, len: Usv },
518    /// A [`LineOp::SetKind`] whose kind contradicts the line's text: tagging
519    /// prose `Island` or `Rule`, or a slot-bearing line `Code`. Export trusts the
520    /// kind over the text, so the write would silently drop the line's content;
521    /// the op-time twin of
522    /// [`Invariant::LineKindMismatch`](crate::model::Invariant::LineKindMismatch),
523    /// refused here because `normalize` does not repair it.
524    LineKindMismatch {
525        line: usize,
526        mismatch: LineKindMismatch,
527    },
528    /// A [`LineOp::SetContainers`] nested a line deeper than
529    /// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH), the op-time twin of
530    /// [`Invariant::NestingTooDeep`](crate::model::Invariant::NestingTooDeep).
531    NestingTooDeep {
532        line: usize,
533        depth: usize,
534        max: usize,
535    },
536}
537
538impl Content {
539    /// Splice `text` via `delta`, rebase marks, sync `lines` to `\n` changes,
540    /// cascade island removal for any deleted slot, then normalize.
541    ///
542    /// Islands stay in lockstep with their [`ISLAND_SLOT`] chars: a delta that
543    /// *deletes* a slot drops the corresponding [`Island`] (the content goes
544    /// away with its slot); a delta that *inserts* a raw slot is rejected
545    /// ([`ApplyError::IslandSlotInInsert`]), islands are created through
546    /// [`IslandOp::Insert`], never a text splice, so a slot arriving here would
547    /// orphan.
548    ///
549    /// Inserted text is sanitized first: `\r` and Unicode bidi controls (the
550    /// chars [`Content::validate`] forbids) are stripped, mirroring the
551    /// normalization `import` applies at the string boundary. The text-delta
552    /// channel is the *other* way text enters the content, so without this an
553    /// insert of `\r` or a bidi control returned `Ok` while leaving a content
554    /// that fails `validate()`.
555    pub fn apply_text_delta(&mut self, delta: &Delta) -> Result<(), ApplyError> {
556        self.apply_text_delta_inner(delta)?;
557        self.normalize();
558        Ok(())
559    }
560
561    /// [`apply_text_delta`](Self::apply_text_delta) without the terminal
562    /// normalize: the stage [`apply_field_change`](Self::apply_field_change)
563    /// runs so a committed bundle canonicalizes once at the end, not after each
564    /// op.
565    fn apply_text_delta_inner(&mut self, delta: &Delta) -> Result<(), ApplyError> {
566        // Reject before mutating: a raw slot in an insert would create a slot
567        // with no backing island. Checked up front so the content is untouched
568        // on this error.
569        for op in &delta.ops {
570            if let Op::Insert(s) = op {
571                if s.contains(ISLAND_SLOT) {
572                    return Err(ApplyError::IslandSlotInInsert);
573                }
574            }
575        }
576
577        // Strip the chars `validate()` forbids (`\r`, bidi controls) from every
578        // insert before they reach the content. Stripping (not rejecting)
579        // mirrors `import`: these are content to normalize away, unlike a raw
580        // slot, which has no backing island and must be refused. Sanitizing the
581        // whole delta up front keeps `try_apply` / `map_pos` / line+island sync
582        // in agreement on one cleaned op stream; a clean delta (every keystroke)
583        // is borrowed through untouched, so the hot path skips the clone.
584        let sanitized = sanitize_inserts(delta);
585        let delta = sanitized.as_ref();
586
587        let old_chars: Vec<char> = self.text.chars().collect();
588        let old_lines = self.lines.clone();
589        // A splice may name only the region it changes: `try_apply` retains the
590        // untouched remainder implicitly, so a bare prepend applies against the
591        // whole content. An over-long delta (consuming more base than exists)
592        // still fails the base-length check.
593        let new_text = delta
594            .try_apply(&self.text)
595            .map_err(|e| ApplyError::DeltaBaseMismatch {
596                expected: e.expected,
597                actual: e.actual,
598            })?;
599
600        self.rebase_marks(delta);
601        let new_len = new_text.chars().count();
602        self.marks.retain(|m| {
603            m.start <= m.end
604                && m.end <= new_len
605                && (m.start < m.end || !m.kind.is_formatting())
606        });
607
608        self.text = new_text;
609        self.lines = sync_lines_for_delta(&old_chars, old_lines, delta);
610        let old_islands = std::mem::take(&mut self.islands);
611        self.islands = sync_islands_for_delta(&old_chars, old_islands, delta);
612        if self.lines.len() != self.segment_count() {
613            return Err(ApplyError::LineCountMismatch {
614                lines: self.lines.len(),
615                segments: self.segment_count(),
616            });
617        }
618        Ok(())
619    }
620
621    /// Rebase every mark's range through `delta`'s
622    /// [`map_pos`](crate::delta::Delta::map_pos): a range mark's start biases
623    /// `After` and its end `Before` (an insertion at either edge grows text
624    /// *outside* the span), a point (zero-width) mark biases `Before`. The one
625    /// mapping the text-delta channel and line split/join both rebase marks by.
626    fn rebase_marks(&mut self, delta: &Delta) {
627        for m in &mut self.marks {
628            if m.start == m.end {
629                let p = delta.map_pos(m.start, Assoc::Before);
630                m.start = p;
631                m.end = p;
632            } else {
633                m.start = delta.map_pos(m.start, Assoc::After);
634                m.end = delta.map_pos(m.end, Assoc::Before);
635            }
636        }
637    }
638
639    /// Apply mark ops in final-text coordinates, then normalize.
640    pub fn apply_mark_ops(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
641        self.apply_mark_ops_inner(ops)?;
642        self.normalize();
643        Ok(())
644    }
645
646    /// [`apply_mark_ops`](Self::apply_mark_ops) without the terminal normalize:
647    /// the bundle's final stage, canonicalized once by
648    /// [`apply_field_change`](Self::apply_field_change).
649    fn apply_mark_ops_inner(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
650        let len = self.len_usv();
651        for op in ops {
652            match op {
653                MarkOp::Add { start, end, kind } => {
654                    if *start > *end || *end > len {
655                        return Err(ApplyError::MarkOutOfRange {
656                            start: *start,
657                            end: *end,
658                            len,
659                        });
660                    }
661                    if kind.is_formatting() && start == end {
662                        return Err(ApplyError::MarkOutOfRange {
663                            start: *start,
664                            end: *end,
665                            len,
666                        });
667                    }
668                    // Anchor id: caller-supplied, unique per `Content`, non-empty
669                    // (`DOCUMENT_STORAGE.md` § Anchor-id identity). Reject a live
670                    // collision (`RemoveAnchor` cannot tell two same-id anchors
671                    // apart) and the empty degenerate handle. Ops apply in
672                    // sequence, so a `RemoveAnchor` earlier in the bundle frees
673                    // the id for re-add here.
674                    if let MarkKind::Anchor { id } = kind {
675                        if id.is_empty() {
676                            return Err(ApplyError::EmptyAnchorId);
677                        }
678                        if self
679                            .marks
680                            .iter()
681                            .any(|m| matches!(&m.kind, MarkKind::Anchor { id: aid } if aid == id))
682                        {
683                            return Err(ApplyError::AnchorIdCollision { id: id.clone() });
684                        }
685                    }
686                    self.marks.push(Mark {
687                        start: *start,
688                        end: *end,
689                        kind: kind.clone(),
690                    });
691                }
692                MarkOp::Remove { start, end, kind } => {
693                    if *start > *end || *end > len {
694                        return Err(ApplyError::MarkOutOfRange {
695                            start: *start,
696                            end: *end,
697                            len,
698                        });
699                    }
700                    let mut next = Vec::with_capacity(self.marks.len());
701                    for m in self.marks.drain(..) {
702                        // Untouched: a different kind, or no overlap with the
703                        // removed range.
704                        if m.kind != *kind || !ranges_overlap(m.start, m.end, *start, *end) {
705                            next.push(m);
706                            continue;
707                        }
708                        // Identity/unknown handles have no range algebra to
709                        // subtract: drop the overlapping one whole.
710                        if !kind.is_formatting() {
711                            continue;
712                        }
713                        // Formatting: subtract [start, end), re-emitting the
714                        // surviving fragments. An edge-aligned removal yields a
715                        // zero-width fragment here; `normalize` drops it.
716                        if m.start < *start {
717                            next.push(Mark {
718                                start: m.start,
719                                end: *start,
720                                kind: m.kind.clone(),
721                            });
722                        }
723                        if *end < m.end {
724                            next.push(Mark {
725                                start: *end,
726                                end: m.end,
727                                kind: m.kind.clone(),
728                            });
729                        }
730                    }
731                    self.marks = next;
732                }
733                MarkOp::RemoveAnchor { id } => {
734                    self.marks
735                        .retain(|m| !matches!(&m.kind, MarkKind::Anchor { id: aid } if aid == id));
736                }
737            }
738        }
739        Ok(())
740    }
741
742    /// Apply island ops: replace an entry by id, or insert a slot and its entry
743    /// together.
744    pub fn apply_island_ops(&mut self, ops: &[IslandOp]) -> Result<(), ApplyError> {
745        self.apply_island_ops_inner(ops)?;
746        self.normalize();
747        Ok(())
748    }
749
750    /// [`apply_island_ops`](Self::apply_island_ops) without the terminal
751    /// normalize: a bundle stage canonicalized once by
752    /// [`apply_field_change`](Self::apply_field_change).
753    fn apply_island_ops_inner(&mut self, ops: &[IslandOp]) -> Result<(), ApplyError> {
754        for op in ops {
755            match op {
756                IslandOp::Set { island } => {
757                    let idx = self
758                        .islands
759                        .iter()
760                        .position(|i| i.id == island.id)
761                        .ok_or_else(|| ApplyError::UnknownIslandId {
762                            id: island.id.clone(),
763                        })?;
764                    // In place: the entry's slot-order index is its slot's, and
765                    // the slot does not move. Nothing here touches `text` or
766                    // `marks`: an island edit costs no anchors.
767                    self.islands[idx] = island.clone();
768                }
769                IslandOp::Insert { at, island } => {
770                    // Refuse before the write, as the mark channel does for an
771                    // anchor id: an empty or colliding id is an island `Set`
772                    // cannot address. Ops apply in sequence, so an earlier
773                    // delete in the same bundle frees the id for reuse here.
774                    if island.id.is_empty() {
775                        return Err(ApplyError::EmptyIslandId);
776                    }
777                    if self.islands.iter().any(|i| i.id == island.id) {
778                        return Err(ApplyError::IslandIdCollision {
779                            id: island.id.clone(),
780                        });
781                    }
782                    let chars: Vec<char> = self.text.chars().collect();
783                    if *at > chars.len() {
784                        return Err(ApplyError::IslandInsertOutOfRange {
785                            at: *at,
786                            len: chars.len(),
787                        });
788                    }
789                    // Islands are stored in slot order, so the entry's index is
790                    // the count of slots before `at`.
791                    let slot_idx = chars[..*at].iter().filter(|&&c| c == ISLAND_SLOT).count();
792                    let byte = char_to_byte(&self.text, *at);
793                    self.text.insert(byte, ISLAND_SLOT);
794                    // Rebase marks through the one-char insertion, as the text
795                    // channel and line split both do: an anchor after the new
796                    // island tracks the splice instead of drifting.
797                    self.rebase_marks(&Delta {
798                        ops: vec![Op::Retain(*at), Op::Insert(ISLAND_SLOT.to_string())],
799                    });
800                    self.islands.insert(slot_idx, island.clone());
801                    // A slot is not a `\n`: the segment count, and so the line
802                    // list, is unchanged.
803                }
804            }
805        }
806        Ok(())
807    }
808
809    /// Apply line ops: split/join splice `\n`; set ops touch metadata only.
810    pub fn apply_line_ops(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
811        self.apply_line_ops_inner(ops)?;
812        self.normalize();
813        Ok(())
814    }
815
816    /// [`apply_line_ops`](Self::apply_line_ops) without the terminal normalize:
817    /// a bundle stage canonicalized once by
818    /// [`apply_field_change`](Self::apply_field_change).
819    fn apply_line_ops_inner(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
820        for op in ops {
821            match op {
822                LineOp::Split { at } => self.split_line(*at)?,
823                LineOp::Join { line } => self.join_line(*line)?,
824                LineOp::SetKind { line, kind } => {
825                    // The kind must agree with the text already on the line:
826                    // export reads the kind and never the segment, so an
827                    // `Island`/`Rule` tag over prose projects the text away.
828                    // Checked before the write (line ops stage on a scratch copy,
829                    // so an error leaves the content untouched).
830                    let seg = self
831                        .text
832                        .split('\n')
833                        .nth(*line)
834                        .ok_or(ApplyError::LineOutOfRange {
835                            line: *line,
836                            lines: self.lines.len(),
837                        })?;
838                    if let Some(mismatch) = line_kind_mismatch(kind, seg) {
839                        return Err(ApplyError::LineKindMismatch {
840                            line: *line,
841                            mismatch,
842                        });
843                    }
844                    let line = self.line_mut(*line)?;
845                    line.kind = kind.clone();
846                }
847                LineOp::SetContainers { line, containers } => {
848                    // Both emitters recurse one frame per container, so an
849                    // over-deep path is a stack overflow at render, not a render
850                    // error. Same cap as import, refused before the write.
851                    if containers.len() > crate::MAX_NESTING_DEPTH {
852                        return Err(ApplyError::NestingTooDeep {
853                            line: *line,
854                            depth: containers.len(),
855                            max: crate::MAX_NESTING_DEPTH,
856                        });
857                    }
858                    let line = self.line_mut(*line)?;
859                    line.containers = containers.clone();
860                }
861                LineOp::SetContinues { line, continues } => {
862                    // Line 0 has nothing before it to continue: setting the flag
863                    // there would forge the `FirstLineContinues` invariant that
864                    // `normalize` does not repair. Reject before the write so the
865                    // content stays valid (`apply_field_change` stages line ops on
866                    // a scratch copy, so this leaves `self` untouched).
867                    if *line == 0 && *continues {
868                        return Err(ApplyError::FirstLineContinues);
869                    }
870                    let l = self.line_mut(*line)?;
871                    l.continues = *continues;
872                }
873            }
874        }
875        Ok(())
876    }
877
878    /// One committed field edit bundle: text delta, then island ops, then line
879    /// ops, then marks, canonicalized by a single terminal
880    /// [`normalize`](Self::normalize).
881    ///
882    /// All-or-nothing: on any op's error `self` is left exactly as it was, so a
883    /// caller need not snapshot-and-restore around a failed bundle. A bundle
884    /// carrying ops has several fallible stages that would otherwise partially
885    /// commit, so it is staged on a scratch copy and swapped in only once every
886    /// stage succeeds. The pure-text-delta path (the per-keystroke hot path)
887    /// skips the clone: `apply_text_delta` validates the delta before mutating,
888    /// so it is already atomic on the errors a caller can provoke.
889    ///
890    /// **Stage order is a coordinate contract**, not a convenience: each stage
891    /// reads the text the earlier ones left. Island ops sit between the delta
892    /// and the line ops because both neighbors need them there. An island insert
893    /// splices a slot, so a `LineOp::SetKind { kind: Island }` in the same bundle
894    /// can only validate against a line that already carries it; `Split`/`Join`
895    /// and every mark range are then measured in a frame that includes the new
896    /// slots. The one-bundle block island ([`IslandOp::Insert`]) follows from
897    /// that.
898    ///
899    /// The stages run on their non-normalizing inner forms and `normalize` runs
900    /// once at the end. One terminal normalize suffices because split/join
901    /// rebase marks through their `\n` splice
902    /// ([`map_pos`](crate::delta::Delta::map_pos) semantics): the
903    /// formatting-edge `\n`-trim then commutes with the line ops (trim-per-stage
904    /// and trim-once converge), and `MarkOp::Remove` is coverage-set
905    /// subtraction, which commutes with `normalize`'s same-kind union
906    /// (`(A ∪ B) \ R = (A\R) ∪ (B\R)`). One canonicalization point, one pass.
907    pub fn apply_field_change(&mut self, bundle: &ChangeBundle) -> Result<(), ApplyError> {
908        if bundle.is_delta_only() {
909            return self.apply_text_delta(&bundle.delta);
910        }
911        let mut scratch = self.clone();
912        scratch.apply_text_delta_inner(&bundle.delta)?;
913        scratch.apply_island_ops_inner(&bundle.island_ops)?;
914        scratch.apply_line_ops_inner(&bundle.line_ops)?;
915        scratch.apply_mark_ops_inner(&bundle.mark_ops)?;
916        scratch.normalize();
917        *self = scratch;
918        Ok(())
919    }
920
921    fn line_mut(&mut self, line: usize) -> Result<&mut Line, ApplyError> {
922        let lines = self.lines.len();
923        self.lines
924            .get_mut(line)
925            .ok_or(ApplyError::LineOutOfRange { line, lines })
926    }
927
928    fn split_line(&mut self, at: Usv) -> Result<(), ApplyError> {
929        let char_indices: Vec<(usize, char)> = self.text.char_indices().collect();
930        let len = char_indices.len();
931        if at > len {
932            return Err(ApplyError::SplitPositionOutOfRange { at, len });
933        }
934        if at > 0 && char_indices[at - 1].1 == '\n' {
935            return Err(ApplyError::SplitAtNewline { at });
936        }
937        if at < len && char_indices[at].1 == '\n' {
938            return Err(ApplyError::SplitAtNewline { at });
939        }
940
941        // `at`'s newline-adjacency neighbors, `at`'s byte offset, and the
942        // newline count before `at` (== the post-insert line index, since the
943        // insertion lands at index `at`, not before it) all come from this
944        // one pass over `char_indices`, instead of four separate text scans.
945        let byte = char_indices.get(at).map_or(self.text.len(), |&(b, _)| b);
946        let line_idx = char_indices[..at].iter().filter(|&(_, c)| *c == '\n').count();
947        self.text.insert(byte, '\n');
948
949        // Rebase marks through the one-char `\n` insertion: the same map_pos
950        // rule the text-delta channel uses, so a split does not drift a mark's
951        // coordinates (a mark spanning `at` grows by the inserted char; the
952        // terminal normalize trims any `\n` edge it lands on).
953        self.rebase_marks(&Delta {
954            ops: vec![Op::Retain(at), Op::Insert("\n".to_string())],
955        });
956
957        let template = self
958            .lines
959            .get(line_idx)
960            .cloned()
961            .unwrap_or_else(default_para_line);
962        let mut new_line = template;
963        new_line.continues = false;
964        self.lines.insert(line_idx + 1, new_line);
965
966        if self.lines.len() != self.segment_count() {
967            return Err(ApplyError::LineCountMismatch {
968                lines: self.lines.len(),
969                segments: self.segment_count(),
970            });
971        }
972        Ok(())
973    }
974
975    fn join_line(&mut self, line: usize) -> Result<(), ApplyError> {
976        if line + 1 >= self.lines.len() {
977            return Err(ApplyError::LineOutOfRange {
978                line,
979                lines: self.lines.len(),
980            });
981        }
982        let nl = newline_at_line_boundary(&self.text, line)?;
983        let byte = char_to_byte(&self.text, nl);
984        self.text.remove(byte);
985
986        // Rebase marks through the one-char `\n` deletion, as the text-delta
987        // channel would: a mark spanning the boundary shrinks by one; one that
988        // covered only the `\n` collapses to zero-width and the terminal
989        // normalize drops it.
990        self.rebase_marks(&Delta {
991            ops: vec![Op::Retain(nl), Op::Delete(1)],
992        });
993
994        self.lines.remove(line + 1);
995
996        if self.lines.len() != self.segment_count() {
997            return Err(ApplyError::LineCountMismatch {
998                lines: self.lines.len(),
999                segments: self.segment_count(),
1000            });
1001        }
1002        Ok(())
1003    }
1004}
1005
1006fn default_para_line() -> Line {
1007    Line {
1008        kind: LineKind::Para,
1009        containers: Vec::new(),
1010        continues: false,
1011    }
1012}
1013
1014fn ranges_overlap(a0: Usv, a1: Usv, b0: Usv, b1: Usv) -> bool {
1015    a0 < b1 && b0 < a1
1016}
1017
1018/// A char the content text may not carry (`validate()` rejects it): a bare `\r`
1019/// or a Unicode bidi formatting control. `\n` is a real line boundary and a raw
1020/// [`ISLAND_SLOT`] is refused separately, so neither belongs here.
1021fn insert_forbidden(c: char) -> bool {
1022    c == '\r' || is_bidi_char(c)
1023}
1024
1025/// Drop [`insert_forbidden`] chars from every `Op::Insert`, returning the delta
1026/// borrowed untouched when no insert carries one (the common keystroke). Mirrors
1027/// the forbidden-char stripping `import` applies (`push_text`, `strip_bidi_
1028/// formatting`); a raw `\r`/bidi arriving through the text-delta channel would
1029/// otherwise persist a content that fails `validate()`.
1030fn sanitize_inserts(delta: &Delta) -> Cow<'_, Delta> {
1031    let needs_cleaning = delta
1032        .ops
1033        .iter()
1034        .any(|op| matches!(op, Op::Insert(s) if s.chars().any(insert_forbidden)));
1035    if !needs_cleaning {
1036        return Cow::Borrowed(delta);
1037    }
1038    let ops = delta
1039        .ops
1040        .iter()
1041        .map(|op| match op {
1042            Op::Insert(s) => Op::Insert(s.chars().filter(|c| !insert_forbidden(*c)).collect()),
1043            other => other.clone(),
1044        })
1045        .collect();
1046    Cow::Owned(Delta { ops })
1047}
1048
1049/// Walk `delta` over `old_chars` and mirror `\n` insert/delete in `lines`,
1050/// building the result in one forward pass: O(old_chars walked + inserts),
1051/// no per-`\n` mid-`Vec` `remove`/`insert`.
1052///
1053/// The cursor sits *in* a line, `cur`; downstream of it is always the untouched
1054/// original suffix (`rest`), because a split lands its clone right at the cursor
1055/// and a delete drops the next original. So the three `\n` events reduce to:
1056/// a retained `\n` finalizes `cur` and pulls the next original into it; a
1057/// deleted `\n` drops the next original (merging it in), when one exists; an
1058/// inserted `\n` finalizes `cur` and makes a clone (its `continues` cleared) the
1059/// new `cur`. `cur == None` is the past-the-end state on a malformed content
1060/// (more `\n` than lines), where a split clones a default line.
1061fn sync_lines_for_delta(old_chars: &[char], old_lines: Vec<Line>, delta: &Delta) -> Vec<Line> {
1062    let cap = old_lines.len();
1063    let mut rest = old_lines.into_iter();
1064    let mut out: Vec<Line> = Vec::with_capacity(cap);
1065    let mut cur: Option<Line> = rest.next();
1066    let mut old = 0usize;
1067
1068    for op in &delta.ops {
1069        match op {
1070            Op::Retain(n) => {
1071                for _ in 0..*n {
1072                    if old >= old_chars.len() {
1073                        break;
1074                    }
1075                    if old_chars[old] == '\n' {
1076                        out.extend(cur.take());
1077                        cur = rest.next();
1078                    }
1079                    old += 1;
1080                }
1081            }
1082            Op::Delete(n) => {
1083                for _ in 0..*n {
1084                    if old >= old_chars.len() {
1085                        break;
1086                    }
1087                    // A deleted '\n' merges the next original into `cur`: drop
1088                    // it. With no next original there is nothing to drop.
1089                    if old_chars[old] == '\n' {
1090                        rest.next();
1091                    }
1092                    old += 1;
1093                }
1094            }
1095            Op::Insert(s) => {
1096                for c in s.chars() {
1097                    if c == '\n' {
1098                        let mut new_line = match cur.take() {
1099                            Some(line) => {
1100                                let clone = line.clone();
1101                                out.push(line);
1102                                clone
1103                            }
1104                            None => default_para_line(),
1105                        };
1106                        new_line.continues = false;
1107                        cur = Some(new_line);
1108                    }
1109                }
1110            }
1111        }
1112    }
1113
1114    out.extend(cur);
1115    out.extend(rest);
1116    out
1117}
1118
1119/// Walk `delta` over `old_chars` and drop any island whose [`ISLAND_SLOT`] char
1120/// was deleted (cascade removal: the island's content goes away with its slot).
1121/// Islands are stored in slot order, so the Nth slot backs the Nth island; a
1122/// deleted slot drops its island and the survivors renumber implicitly. Raw
1123/// slot *inserts* are rejected upstream, so an insert never mints a new slot.
1124fn sync_islands_for_delta(
1125    old_chars: &[char],
1126    old_islands: Vec<Island>,
1127    delta: &Delta,
1128) -> Vec<Island> {
1129    let mut keep = vec![true; old_islands.len()];
1130    let mut old = 0usize;
1131    let mut slot_idx = 0usize;
1132
1133    for op in &delta.ops {
1134        match op {
1135            Op::Retain(n) => {
1136                for _ in 0..*n {
1137                    if old >= old_chars.len() {
1138                        break;
1139                    }
1140                    if old_chars[old] == ISLAND_SLOT {
1141                        slot_idx += 1;
1142                    }
1143                    old += 1;
1144                }
1145            }
1146            Op::Delete(n) => {
1147                for _ in 0..*n {
1148                    if old >= old_chars.len() {
1149                        break;
1150                    }
1151                    if old_chars[old] == ISLAND_SLOT {
1152                        if let Some(k) = keep.get_mut(slot_idx) {
1153                            *k = false;
1154                        }
1155                        slot_idx += 1;
1156                    }
1157                    old += 1;
1158                }
1159            }
1160            // Inserts add no slots (a raw ISLAND_SLOT insert is rejected before
1161            // this walk), so they never touch the island list.
1162            Op::Insert(_) => {}
1163        }
1164    }
1165
1166    old_islands
1167        .into_iter()
1168        .zip(keep)
1169        .filter_map(|(island, keep)| keep.then_some(island))
1170        .collect()
1171}
1172
1173fn newline_at_line_boundary(text: &str, line: usize) -> Result<Usv, ApplyError> {
1174    let mut current = 0usize;
1175    for (i, c) in text.chars().enumerate() {
1176        if c == '\n' {
1177            if current == line {
1178                return Ok(i);
1179            }
1180            current += 1;
1181        }
1182    }
1183    Err(ApplyError::LineOutOfRange {
1184        line,
1185        lines: text.chars().filter(|&c| c == '\n').count() + 1,
1186    })
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192    use crate::delta::diff;
1193    use crate::import::from_markdown;
1194
1195    #[test]
1196    fn mark_op_wire_round_trips_each_variant() {
1197        let ops = vec![
1198            MarkOp::Add {
1199                start: 0,
1200                end: 3,
1201                kind: MarkKind::Strong,
1202            },
1203            MarkOp::Add {
1204                start: 1,
1205                end: 2,
1206                kind: MarkKind::Link {
1207                    url: "https://x".into(),
1208                },
1209            },
1210            MarkOp::Remove {
1211                start: 4,
1212                end: 6,
1213                kind: MarkKind::Anchor { id: "c1".into() },
1214            },
1215            MarkOp::RemoveAnchor { id: "c2".into() },
1216        ];
1217        for op in ops {
1218            let v = mark_op_to_value(&op);
1219            assert_eq!(mark_op_from_value(&v).unwrap(), op, "round-trip: {v}");
1220        }
1221    }
1222
1223    #[test]
1224    fn line_op_wire_round_trips_each_variant() {
1225        let ops = vec![
1226            LineOp::Split { at: 5 },
1227            LineOp::Join { line: 1 },
1228            LineOp::SetKind {
1229                line: 0,
1230                kind: LineKind::Heading { level: 2 },
1231            },
1232            LineOp::SetContainers {
1233                line: 2,
1234                containers: vec![Container::Quote],
1235            },
1236            // The open block vocabulary rides the same op wire:
1237            // a host can set a role or a container this build does not know.
1238            LineOp::SetKind {
1239                line: 0,
1240                kind: LineKind::Unknown {
1241                    tag: "callout".into(),
1242                    attrs: serde_json::json!({"variant": "warn"}),
1243                },
1244            },
1245            LineOp::SetContainers {
1246                line: 2,
1247                containers: vec![Container::Unknown {
1248                    tag: "indent".into(),
1249                    attrs: serde_json::json!({"depth": 2}),
1250                }],
1251            },
1252            LineOp::SetContinues {
1253                line: 1,
1254                continues: true,
1255            },
1256            LineOp::SetContinues {
1257                line: 3,
1258                continues: false,
1259            },
1260        ];
1261        for op in ops {
1262            let v = line_op_to_value(&op);
1263            assert_eq!(line_op_from_value(&v).unwrap(), op, "round-trip: {v}");
1264        }
1265    }
1266
1267    /// On the op lane, `attrs` beside a built-in discriminator is a
1268    /// shape error. A host that emits one classified a built-in as unknown
1269    /// (stale copy of the built-in list) and the lenient reader would resolve the
1270    /// name and drop the payload unread, corrupting the line with no diagnostic.
1271    #[test]
1272    fn op_wire_rejects_attrs_beside_a_built_in_name() {
1273        let bad = serde_json::json!({
1274            "op": "setKind", "line": 0, "kind": "para", "attrs": {"tone": "warn"},
1275        });
1276        assert!(matches!(line_op_from_value(&bad), Err(ParseError::Shape(_))));
1277        let bad = serde_json::json!({
1278            "op": "setContainers", "line": 0,
1279            "containers": [{"container": "quote", "attrs": {"k": 1}}],
1280        });
1281        assert!(matches!(line_op_from_value(&bad), Err(ParseError::Shape(_))));
1282        let bad = serde_json::json!({
1283            "op": "add", "start": 0, "end": 1, "type": "strong", "attrs": {"k": 1},
1284        });
1285        assert!(matches!(mark_op_from_value(&bad), Err(ParseError::Shape(_))));
1286
1287        // An unknown name keeps carrying `attrs` (the rule is reserved-name
1288        // reuse, not `attrs` itself) and a built-in without `attrs` is untouched.
1289        for ok in [
1290            serde_json::json!({"op": "setKind", "line": 0, "kind": "callout", "attrs": {"tone": "warn"}}),
1291            serde_json::json!({"op": "setKind", "line": 0, "kind": "heading", "level": 2}),
1292        ] {
1293            assert!(line_op_from_value(&ok).is_ok(), "rejected: {ok}");
1294        }
1295    }
1296
1297    #[test]
1298    fn delta_serde_shape() {
1299        let d = Delta {
1300            ops: vec![Op::Retain(2), Op::Insert("hi".into()), Op::Delete(1)],
1301        };
1302        let v = serde_json::to_value(&d).unwrap();
1303        assert_eq!(
1304            v,
1305            serde_json::json!({"ops": [{"retain": 2}, {"insert": "hi"}, {"delete": 1}]})
1306        );
1307        assert_eq!(serde_json::from_value::<Delta>(v).unwrap(), d);
1308    }
1309
1310    #[test]
1311    fn apply_text_delta_rebases_marks() {
1312        let mut rt = from_markdown("hello").unwrap();
1313        rt.marks.push(Mark {
1314            start: 1,
1315            end: 4,
1316            kind: MarkKind::Strong,
1317        });
1318        rt.normalize();
1319        let d = diff("hello", "hXello");
1320        rt.apply_text_delta(&d).unwrap();
1321        let strong = rt
1322            .marks
1323            .iter()
1324            .find(|m| matches!(m.kind, MarkKind::Strong))
1325            .unwrap();
1326        assert_eq!((strong.start, strong.end), (2, 5));
1327        assert_eq!(rt.text, "hXello");
1328    }
1329
1330    #[test]
1331    fn apply_text_delta_pads_short_prepend() {
1332        // A bare prepend names only its inserted text (no trailing retain); it
1333        // still splices against the whole content rather than failing the base
1334        // check (regression for the per-field delta path).
1335        let mut rt = from_markdown("hello").unwrap();
1336        rt.apply_text_delta(&Delta {
1337            ops: vec![Op::Insert("NEW ".into())],
1338        })
1339        .unwrap();
1340        assert_eq!(rt.text, "NEW hello");
1341    }
1342
1343    #[test]
1344    fn apply_text_delta_rejects_over_long_delta() {
1345        // Consuming more base than exists is a wrong-revision delta, not an
1346        // abbreviated one: it still fails closed.
1347        let mut rt = from_markdown("hi").unwrap();
1348        assert!(matches!(
1349            rt.apply_text_delta(&Delta {
1350                ops: vec![Op::Retain(99)],
1351            }),
1352            Err(ApplyError::DeltaBaseMismatch { .. })
1353        ));
1354        assert_eq!(rt.text, "hi");
1355    }
1356
1357    #[test]
1358    fn apply_mark_ops_add_and_remove() {
1359        let mut rt = from_markdown("abcd").unwrap();
1360        rt.apply_mark_ops(&[MarkOp::Add {
1361            start: 0,
1362            end: 2,
1363            kind: MarkKind::Emph,
1364        }])
1365        .unwrap();
1366        assert!(rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
1367        rt.apply_mark_ops(&[MarkOp::Remove {
1368            start: 0,
1369            end: 4,
1370            kind: MarkKind::Emph,
1371        }])
1372        .unwrap();
1373        assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
1374    }
1375
1376    #[test]
1377    fn apply_mark_ops_remove_punches_hole() {
1378        // Un-formatting the middle of a run leaves the two non-overlapping
1379        // fragments, not an empty mark set. Strong[0,6) over
1380        // "abcdef", Remove[2,4) -> Strong[0,2) + Strong[4,6).
1381        let mut rt = from_markdown("abcdef").unwrap();
1382        rt.apply_mark_ops(&[MarkOp::Add {
1383            start: 0,
1384            end: 6,
1385            kind: MarkKind::Strong,
1386        }])
1387        .unwrap();
1388        rt.apply_mark_ops(&[MarkOp::Remove {
1389            start: 2,
1390            end: 4,
1391            kind: MarkKind::Strong,
1392        }])
1393        .unwrap();
1394        let strong: Vec<_> = rt
1395            .marks
1396            .iter()
1397            .filter(|m| matches!(m.kind, MarkKind::Strong))
1398            .map(|m| (m.start, m.end))
1399            .collect();
1400        assert_eq!(strong, vec![(0, 2), (4, 6)]);
1401    }
1402
1403    #[test]
1404    fn apply_mark_ops_remove_at_edge_leaves_no_zero_width() {
1405        // A removal flush against the mark's start yields a zero-width left
1406        // fragment [0,0); normalize drops it, leaving only the right fragment.
1407        let mut rt = from_markdown("abcdef").unwrap();
1408        rt.apply_mark_ops(&[MarkOp::Add {
1409            start: 0,
1410            end: 6,
1411            kind: MarkKind::Strong,
1412        }])
1413        .unwrap();
1414        rt.apply_mark_ops(&[MarkOp::Remove {
1415            start: 0,
1416            end: 2,
1417            kind: MarkKind::Strong,
1418        }])
1419        .unwrap();
1420        let strong: Vec<_> = rt
1421            .marks
1422            .iter()
1423            .filter(|m| matches!(m.kind, MarkKind::Strong))
1424            .map(|m| (m.start, m.end))
1425            .collect();
1426        assert_eq!(strong, vec![(2, 6)]);
1427    }
1428
1429    #[test]
1430    fn apply_mark_ops_remove_covering_range_drops_mark() {
1431        // A removal that fully covers the mark leaves nothing (both fragments
1432        // zero-width or inverted): the whole-drop case still holds.
1433        let mut rt = from_markdown("abcdef").unwrap();
1434        rt.apply_mark_ops(&[MarkOp::Add {
1435            start: 2,
1436            end: 4,
1437            kind: MarkKind::Emph,
1438        }])
1439        .unwrap();
1440        rt.apply_mark_ops(&[MarkOp::Remove {
1441            start: 0,
1442            end: 6,
1443            kind: MarkKind::Emph,
1444        }])
1445        .unwrap();
1446        assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
1447    }
1448
1449    #[test]
1450    fn apply_mark_ops_remove_non_formatting_drops_whole() {
1451        // Identity/unknown handles can't be range-fragmented: an overlapping
1452        // one is dropped whole, never split into fragments.
1453        let mut rt = from_markdown("abcdef").unwrap();
1454        rt.marks.push(Mark {
1455            start: 0,
1456            end: 6,
1457            kind: MarkKind::Unknown {
1458                tag: "x".into(),
1459                attrs: serde_json::json!({}),
1460            },
1461        });
1462        rt.normalize();
1463        rt.apply_mark_ops(&[MarkOp::Remove {
1464            start: 2,
1465            end: 4,
1466            kind: MarkKind::Unknown {
1467                tag: "x".into(),
1468                attrs: serde_json::json!({}),
1469            },
1470        }])
1471        .unwrap();
1472        assert!(!rt
1473            .marks
1474            .iter()
1475            .any(|m| matches!(m.kind, MarkKind::Unknown { .. })));
1476    }
1477
1478    #[test]
1479    fn apply_text_delta_splits_lines_on_newline_insert() {
1480        let mut rt = from_markdown("one two").unwrap();
1481        let d = diff("one two", "one\ntwo");
1482        rt.apply_text_delta(&d).unwrap();
1483        assert_eq!(rt.lines.len(), 2);
1484        assert_eq!(rt.segment_count(), 2);
1485        assert_eq!(rt.validate(), Ok(()));
1486    }
1487
1488    #[test]
1489    fn line_op_split_and_join() {
1490        let mut rt = from_markdown("onetwo").unwrap();
1491        rt.apply_line_ops(&[LineOp::Split { at: 3 }]).unwrap();
1492        assert_eq!(rt.text, "one\ntwo");
1493        assert_eq!(rt.lines.len(), 2);
1494
1495        rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
1496        assert_eq!(rt.text, "onetwo");
1497        assert_eq!(rt.lines.len(), 1);
1498        assert_eq!(rt.validate(), Ok(()));
1499    }
1500
1501    #[test]
1502    fn line_op_set_kind() {
1503        let mut rt = from_markdown("title").unwrap();
1504        rt.apply_line_ops(&[LineOp::SetKind {
1505            line: 0,
1506            kind: LineKind::Heading { level: 2 },
1507        }])
1508        .unwrap();
1509        assert!(matches!(rt.lines[0].kind, LineKind::Heading { level: 2 }));
1510    }
1511
1512    /// `SetKind` may not tag a line with a kind its text
1513    /// contradicts: export reads the kind and not the segment, so the write
1514    /// would project the line's content away. Refused before the write, so the
1515    /// content is untouched.
1516    #[test]
1517    fn line_op_set_kind_refuses_a_kind_the_text_contradicts() {
1518        let mut rt = from_markdown("hello world").unwrap();
1519        assert_eq!(
1520            rt.apply_line_ops(&[LineOp::SetKind {
1521                line: 0,
1522                kind: LineKind::Island,
1523            }]),
1524            Err(ApplyError::LineKindMismatch {
1525                line: 0,
1526                mismatch: LineKindMismatch::IslandNotOneSlot,
1527            })
1528        );
1529        assert_eq!(
1530            rt.apply_line_ops(&[LineOp::SetKind {
1531                line: 0,
1532                kind: LineKind::Rule,
1533            }]),
1534            Err(ApplyError::LineKindMismatch {
1535                line: 0,
1536                mismatch: LineKindMismatch::RuleNotEmpty,
1537            })
1538        );
1539        assert_eq!(rt.text, "hello world");
1540        assert_eq!(rt.lines[0].kind, LineKind::Para);
1541        assert_eq!(rt.validate(), Ok(()));
1542
1543        // A table island's line tagged `Code` would fence the slot, which
1544        // re-imports as nothing.
1545        let mut tbl = from_markdown("| a | b |\n|---|---|\n| 1 | 2 |").unwrap();
1546        assert_eq!(
1547            tbl.apply_line_ops(&[LineOp::SetKind {
1548                line: 0,
1549                kind: LineKind::Code { lang: None },
1550            }]),
1551            Err(ApplyError::LineKindMismatch {
1552                line: 0,
1553                mismatch: LineKindMismatch::CodeHasSlot,
1554            })
1555        );
1556        assert_eq!(tbl.lines[0].kind, LineKind::Island);
1557    }
1558
1559    /// `SetContainers` is capped at the depth both emitters can
1560    /// recurse: the op-time twin of the `validate` invariant.
1561    #[test]
1562    fn line_op_set_containers_is_depth_capped() {
1563        let mut rt = from_markdown("hi").unwrap();
1564        let deep = vec![Container::Quote; crate::MAX_NESTING_DEPTH + 1];
1565        assert_eq!(
1566            rt.apply_line_ops(&[LineOp::SetContainers {
1567                line: 0,
1568                containers: deep,
1569            }]),
1570            Err(ApplyError::NestingTooDeep {
1571                line: 0,
1572                depth: crate::MAX_NESTING_DEPTH + 1,
1573                max: crate::MAX_NESTING_DEPTH,
1574            })
1575        );
1576        assert!(rt.lines[0].containers.is_empty());
1577    }
1578
1579    #[test]
1580    fn line_op_set_continues_sets_and_clears() {
1581        // Two paragraph lines (delta-split → both `continues: false`, i.e. two
1582        // blocks). `setContinues` on line 1 turns the boundary into a within-block
1583        // hard break, and export then emits one block, not two paragraphs.
1584        let mut rt = from_markdown("one two").unwrap();
1585        rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
1586        assert!(!rt.lines[1].continues, "delta-split newline is a new block");
1587
1588        rt.apply_line_ops(&[LineOp::SetContinues {
1589            line: 1,
1590            continues: true,
1591        }])
1592        .unwrap();
1593        assert!(rt.lines[1].continues);
1594        assert_eq!(rt.validate(), Ok(()));
1595        assert_eq!(
1596            crate::export::to_markdown(&rt).matches("\n\n").count(),
1597            0,
1598            "a within-block hard break is not a paragraph boundary"
1599        );
1600
1601        // Clearing restores the block boundary.
1602        rt.apply_line_ops(&[LineOp::SetContinues {
1603            line: 1,
1604            continues: false,
1605        }])
1606        .unwrap();
1607        assert!(!rt.lines[1].continues);
1608        assert_eq!(rt.validate(), Ok(()));
1609    }
1610
1611    #[test]
1612    fn line_op_set_continues_rejects_first_line() {
1613        let mut rt = from_markdown("one two").unwrap();
1614        rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
1615        let before = rt.clone();
1616        // `continues: true` on line 0 forges `FirstLineContinues`; refused, and
1617        // the content is left untouched.
1618        assert_eq!(
1619            rt.apply_line_ops(&[LineOp::SetContinues {
1620                line: 0,
1621                continues: true,
1622            }]),
1623            Err(ApplyError::FirstLineContinues)
1624        );
1625        assert_eq!(rt, before, "rejected op leaves the content untouched");
1626        // Clearing line 0 (already `false`) is a no-op, not an error.
1627        rt.apply_line_ops(&[LineOp::SetContinues {
1628            line: 0,
1629            continues: false,
1630        }])
1631        .unwrap();
1632        assert_eq!(rt.validate(), Ok(()));
1633    }
1634
1635    fn island(id: &str) -> Island {
1636        Island {
1637            id: id.into(),
1638            island_type: "image".into(),
1639            props: serde_json::json!({}),
1640            loss: crate::model::Loss::LOSSLESS,
1641        }
1642    }
1643
1644    /// A single-line content `ab` (one inline island slot, one backing island).
1645    fn content_with_island() -> Content {
1646        let mut rt = Content::empty();
1647        rt.text = format!("a{ISLAND_SLOT}b");
1648        rt.lines = vec![Line {
1649            kind: LineKind::Para,
1650            containers: vec![],
1651            continues: false,
1652        }];
1653        rt.islands = vec![island("i1")];
1654        assert_eq!(rt.validate(), Ok(()));
1655        rt
1656    }
1657
1658    #[test]
1659    fn delete_slot_cascades_island_removal() {
1660        let mut rt = content_with_island();
1661        // Delete the slot char at index 1 (`ab` -> `ab`).
1662        let d = Delta {
1663            ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(1)],
1664        };
1665        rt.apply_text_delta(&d).unwrap();
1666        assert_eq!(rt.text, "ab");
1667        assert!(rt.islands.is_empty(), "island cascaded away with its slot");
1668        // slot count now equals islands.len(): validate confirms the sync.
1669        assert_eq!(rt.validate(), Ok(()));
1670    }
1671
1672    #[test]
1673    fn delete_one_of_two_slots_removes_the_matching_island() {
1674        let mut rt = Content::empty();
1675        rt.text = format!("{ISLAND_SLOT}x{ISLAND_SLOT}");
1676        rt.lines = vec![Line {
1677            kind: LineKind::Para,
1678            containers: vec![],
1679            continues: false,
1680        }];
1681        rt.islands = vec![island("first"), island("second")];
1682        assert_eq!(rt.validate(), Ok(()));
1683
1684        // Delete the FIRST slot (index 0): `x` -> `x`.
1685        let d = Delta {
1686            ops: vec![Op::Delete(1), Op::Retain(2)],
1687        };
1688        rt.apply_text_delta(&d).unwrap();
1689        assert_eq!(rt.text, format!("x{ISLAND_SLOT}"));
1690        // The surviving island is the second one: the cascade removed the
1691        // island whose slot was deleted, not merely the last entry.
1692        assert_eq!(rt.islands.len(), 1);
1693        assert_eq!(rt.islands[0].id, "second");
1694        assert_eq!(rt.validate(), Ok(()));
1695    }
1696
1697    #[test]
1698    fn insert_raw_slot_is_rejected() {
1699        let mut rt = from_markdown("ab").unwrap();
1700        // An Op::Insert carrying a raw U+FFFC would orphan a slot: reject it.
1701        let d = Delta {
1702            ops: vec![
1703                Op::Retain(1),
1704                Op::Insert(ISLAND_SLOT.to_string()),
1705                Op::Retain(1),
1706            ],
1707        };
1708        assert_eq!(rt.apply_text_delta(&d), Err(ApplyError::IslandSlotInInsert));
1709        // Content untouched on the rejected insert (checked before any mutation).
1710        assert_eq!(rt.text, "ab");
1711        assert!(rt.islands.is_empty());
1712        assert_eq!(rt.validate(), Ok(()));
1713    }
1714
1715    #[test]
1716    fn insert_carriage_return_is_stripped() {
1717        // A `\r` in an insert is dropped, not persisted: the content stays
1718        // valid instead of the op returning Ok over a `CarriageReturn`
1719        // violation. `\r\n` still yields the line-boundary `\n`.
1720        let mut rt = from_markdown("ab").unwrap();
1721        let d = Delta {
1722            ops: vec![Op::Retain(1), Op::Insert("\r".into()), Op::Retain(1)],
1723        };
1724        rt.apply_text_delta(&d).unwrap();
1725        assert_eq!(rt.text, "ab");
1726        assert_eq!(rt.validate(), Ok(()));
1727    }
1728
1729    #[test]
1730    fn insert_bidi_control_is_stripped() {
1731        // A bidi override (U+202E) in an insert is dropped: the content stays
1732        // valid and import's Trojan-source defense is not bypassed.
1733        let mut rt = from_markdown("ab").unwrap();
1734        let d = Delta {
1735            ops: vec![
1736                Op::Retain(1),
1737                Op::Insert("\u{202E}".into()),
1738                Op::Retain(1),
1739            ],
1740        };
1741        rt.apply_text_delta(&d).unwrap();
1742        assert_eq!(rt.text, "ab");
1743        assert_eq!(rt.validate(), Ok(()));
1744    }
1745
1746    #[test]
1747    fn insert_crlf_keeps_the_newline_and_splits() {
1748        // Stripping only the `\r` of a `\r\n` leaves a real line boundary: the
1749        // insert still splits the line, and slot/line sync stays intact.
1750        let mut rt = from_markdown("ab").unwrap();
1751        let d = Delta {
1752            ops: vec![Op::Retain(1), Op::Insert("\r\n".into()), Op::Retain(1)],
1753        };
1754        rt.apply_text_delta(&d).unwrap();
1755        assert_eq!(rt.text, "a\nb");
1756        assert_eq!(rt.lines.len(), 2);
1757        assert_eq!(rt.validate(), Ok(()));
1758    }
1759
1760    #[test]
1761    fn insert_of_clean_text_is_not_reallocated() {
1762        // The hot path: a delta whose inserts carry no forbidden char borrows
1763        // through `sanitize_inserts` unchanged.
1764        let d = Delta {
1765            ops: vec![Op::Retain(1), Op::Insert("clean\n".into()), Op::Retain(1)],
1766        };
1767        assert!(matches!(sanitize_inserts(&d), Cow::Borrowed(_)));
1768    }
1769
1770    /// A bundle carrying a text delta and mark ops alone.
1771    fn mark_bundle(delta: Delta, mark_ops: Vec<MarkOp>) -> ChangeBundle {
1772        ChangeBundle {
1773            delta,
1774            mark_ops,
1775            ..Default::default()
1776        }
1777    }
1778
1779    /// A bundle carrying island ops alone.
1780    fn island_bundle(island_ops: Vec<IslandOp>) -> ChangeBundle {
1781        ChangeBundle {
1782            island_ops,
1783            ..Default::default()
1784        }
1785    }
1786
1787    /// A one-cell table island's props, so a `Set` lands a shape `normalize`
1788    /// leaves alone and `validate` accepts.
1789    fn table_props(header: &str, cell: &str) -> serde_json::Value {
1790        serde_json::json!({
1791            "header": [{ "text": header, "marks": [] }],
1792            "rows": [[{ "text": cell, "marks": [] }]],
1793            "aligns": ["none"],
1794        })
1795    }
1796
1797    /// A minimal image island: the cheapest well-formed `Insert` payload.
1798    fn image(id: &str) -> Island {
1799        Island::new(id.into(), "image".into())
1800            .with_props(serde_json::json!({ "url": "u", "alt": "a" }))
1801    }
1802
1803    #[test]
1804    fn island_op_wire_round_trips_each_variant() {
1805        let island = Island::new("isl-0".into(), "table".into())
1806            .with_props(table_props("H", "a"))
1807            .with_loss(crate::model::Loss::DEGRADED);
1808        let ops = vec![
1809            IslandOp::Set {
1810                island: island.clone(),
1811            },
1812            IslandOp::Insert { at: 7, island },
1813        ];
1814        for op in ops {
1815            let v = island_op_to_value(&op);
1816            assert_eq!(island_op_from_value(&v).unwrap(), op, "round-trip: {v}");
1817        }
1818    }
1819
1820    /// The motivating case: an island payload edit moves the island entry alone,
1821    /// so an anchor elsewhere in the field survives an edit that a whole-value
1822    /// `install` would have cleared.
1823    #[test]
1824    fn island_set_edits_props_and_keeps_the_field_anchors() {
1825        let mut rt = from_markdown("intro\n\n| H |\n| --- |\n| a |").unwrap();
1826        assert_eq!(rt.islands.len(), 1, "one table island");
1827        let id = rt.islands[0].id.clone();
1828        rt.apply_mark_ops(&[MarkOp::Add {
1829            start: 0,
1830            end: 5,
1831            kind: MarkKind::Anchor { id: "c1".into() },
1832        }])
1833        .unwrap();
1834
1835        rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
1836            island: Island::new(id.clone(), "table".into()).with_props(table_props("H", "b")),
1837        }]))
1838        .unwrap();
1839
1840        assert_eq!(rt.islands.len(), 1);
1841        assert_eq!(rt.islands[0].id, id, "the id is target and stored value");
1842        assert_eq!(rt.islands[0].props, table_props("H", "b"));
1843        let anchor = rt
1844            .marks
1845            .iter()
1846            .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
1847            .expect("the anchor above the table survives the island edit");
1848        assert_eq!((anchor.start, anchor.end), (0, 5));
1849        assert_eq!(rt.validate(), Ok(()));
1850    }
1851
1852    /// A `Set` whose id names no island is refused, never a silent no-op: the
1853    /// store must not keep the old island while the caller believes it committed.
1854    #[test]
1855    fn island_set_rejects_an_unknown_id() {
1856        let mut rt = from_markdown("| H |\n| --- |\n| a |").unwrap();
1857        let before = rt.clone();
1858        assert_eq!(
1859            rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
1860                island: Island::new("isl-nope".into(), "table".into())
1861                    .with_props(table_props("H", "b")),
1862            }])),
1863            Err(ApplyError::UnknownIslandId {
1864                id: "isl-nope".into()
1865            })
1866        );
1867        assert_eq!(rt, before);
1868    }
1869
1870    /// `Insert` mints the slot and its entry together, so the slot count and the
1871    /// island list stay in lockstep with no orphan window.
1872    #[test]
1873    fn island_insert_adds_the_slot_and_its_entry() {
1874        let mut rt = from_markdown("ab").unwrap();
1875        rt.apply_mark_ops(&[MarkOp::Add {
1876            start: 0,
1877            end: 1,
1878            kind: MarkKind::Anchor { id: "c1".into() },
1879        }])
1880        .unwrap();
1881
1882        rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
1883            at: 1,
1884            island: Island::new("isl-new".into(), "image".into())
1885                .with_props(serde_json::json!({ "url": "u", "alt": "a" })),
1886        }]))
1887        .unwrap();
1888
1889        assert_eq!(rt.text, format!("a{ISLAND_SLOT}b"));
1890        assert_eq!(rt.islands.len(), 1);
1891        assert_eq!(rt.islands[0].id, "isl-new");
1892        assert_eq!(rt.validate(), Ok(()), "slot count matches the island list");
1893        // The anchor before the slot is untouched; one after would have moved
1894        // with the splice.
1895        let anchor = rt
1896            .marks
1897            .iter()
1898            .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
1899            .expect("anchor survives");
1900        assert_eq!((anchor.start, anchor.end), (0, 1));
1901    }
1902
1903    /// Island ops sequence: op *n*'s `at` counts the slots ops `0..n` already
1904    /// spliced, not the shared post-delta frame, and its entry files at the
1905    /// slot-order index that frame gives rather than at emission order. Slots
1906    /// after `a` and `b` of `abc` go in at 1 and 3, and an op at an earlier
1907    /// position emitted last still files first. Both assertions land
1908    /// differently under the post-delta-only reading, which errors neither way.
1909    #[test]
1910    fn island_inserts_apply_in_sequence() {
1911        let mut rt = from_markdown("xabc").unwrap();
1912        rt.apply_field_change(&ChangeBundle {
1913            // Post-delta: the deleted `x` is out of the frame the ops read.
1914            delta: diff("xabc", "abc"),
1915            island_ops: vec![
1916                IslandOp::Insert {
1917                    at: 1,
1918                    island: image("isl-b"),
1919                },
1920                // 3, not 2: op 0's slot is in the frame this op reads.
1921                IslandOp::Insert {
1922                    at: 3,
1923                    island: image("isl-c"),
1924                },
1925                // An earlier position emitted last, so its slot lands first.
1926                IslandOp::Insert {
1927                    at: 1,
1928                    island: image("isl-a"),
1929                },
1930            ],
1931            ..Default::default()
1932        })
1933        .unwrap();
1934
1935        assert_eq!(
1936            rt.text,
1937            format!("a{ISLAND_SLOT}{ISLAND_SLOT}b{ISLAND_SLOT}c")
1938        );
1939        let ids: Vec<&str> = rt.islands.iter().map(|i| i.id.as_str()).collect();
1940        assert_eq!(ids, ["isl-a", "isl-b", "isl-c"], "slot order, not emission");
1941        assert_eq!(rt.validate(), Ok(()));
1942    }
1943
1944    /// A computed splice carrying a slot is refused whole; the same edit lands
1945    /// as its split form, the slot-free delta plus one `Insert` per slot.
1946    #[test]
1947    fn slot_bearing_splice_splits_into_delta_and_insert() {
1948        let mut rt = from_markdown("ab").unwrap();
1949        let before = rt.clone();
1950
1951        let paste = format!("x{ISLAND_SLOT}y");
1952        assert_eq!(
1953            rt.apply_field_change(&ChangeBundle::from_delta(Delta {
1954                ops: vec![Op::Retain(1), Op::Insert(paste)],
1955            })),
1956            Err(ApplyError::IslandSlotInInsert)
1957        );
1958        assert_eq!(rt, before, "the refusal commits nothing");
1959
1960        rt.apply_field_change(&ChangeBundle {
1961            delta: Delta {
1962                ops: vec![Op::Retain(1), Op::Insert("xy".into())],
1963            },
1964            // The delta leaves `axyb`; the slot goes between `x` and `y`.
1965            island_ops: vec![IslandOp::Insert {
1966                at: 2,
1967                island: image("isl-p"),
1968            }],
1969            ..Default::default()
1970        })
1971        .unwrap();
1972        assert_eq!(rt.text, format!("ax{ISLAND_SLOT}yb"));
1973        assert_eq!(rt.islands[0].id, "isl-p");
1974        assert_eq!(rt.validate(), Ok(()));
1975    }
1976
1977    /// The delete cascade is whole: the payload leaves the store with its slot,
1978    /// so re-landing the island re-inserts the value the producer held, under
1979    /// the original id the drop freed.
1980    #[test]
1981    fn island_delete_then_restore_round_trips() {
1982        let mut rt = from_markdown("ab").unwrap();
1983        rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
1984            at: 1,
1985            island: image("isl-a"),
1986        }]))
1987        .unwrap();
1988        let before = rt.clone();
1989        let held = rt.islands[0].clone();
1990
1991        rt.apply_field_change(&ChangeBundle::from_delta(diff(&before.text, "ab")))
1992            .unwrap();
1993        assert!(rt.islands.is_empty(), "the payload goes with its slot");
1994
1995        rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
1996            at: 1,
1997            island: held,
1998        }]))
1999        .unwrap();
2000        assert_eq!(rt, before, "same content, original id included");
2001    }
2002
2003    /// A block island's line demotes to `Para` when its slot goes: the kind
2004    /// stops matching the text and `normalize` repairs rather than fails.
2005    /// Re-landing one therefore carries the re-tag, not the island op alone.
2006    #[test]
2007    fn block_island_restore_retags_its_line() {
2008        let mut rt = from_markdown("intro").unwrap();
2009        rt.apply_field_change(&ChangeBundle {
2010            delta: diff("intro", "intro\n"),
2011            island_ops: vec![IslandOp::Insert {
2012                at: 6,
2013                island: image("isl-a"),
2014            }],
2015            line_ops: vec![LineOp::SetKind {
2016                line: 1,
2017                kind: LineKind::Island,
2018            }],
2019            ..Default::default()
2020        })
2021        .unwrap();
2022        let before = rt.clone();
2023        let held = rt.islands[0].clone();
2024
2025        rt.apply_field_change(&ChangeBundle::from_delta(diff(&before.text, "intro\n")))
2026            .unwrap();
2027        assert!(rt.islands.is_empty());
2028        assert_eq!(rt.lines[1].kind, LineKind::Para, "demoted, not failed");
2029
2030        // The line stayed open, so the restore is the island op and the re-tag;
2031        // only a delete that took the `\n` too would need a delta.
2032        rt.apply_field_change(&ChangeBundle {
2033            island_ops: vec![IslandOp::Insert { at: 6, island: held }],
2034            line_ops: vec![LineOp::SetKind {
2035                line: 1,
2036                kind: LineKind::Island,
2037            }],
2038            ..Default::default()
2039        })
2040        .unwrap();
2041        assert_eq!(rt, before, "same content, original id and kind included");
2042    }
2043
2044    /// An inserted island's id is caller-supplied on an anchor id's terms:
2045    /// non-empty and unused, since `Set` addresses by it.
2046    #[test]
2047    fn island_insert_id_and_position_rules() {
2048        let mut rt = from_markdown("ab").unwrap();
2049        assert_eq!(
2050            rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
2051                at: 1,
2052                island: image(""),
2053            }])),
2054            Err(ApplyError::EmptyIslandId)
2055        );
2056        assert_eq!(
2057            rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
2058                at: 9,
2059                island: image("isl-a"),
2060            }])),
2061            Err(ApplyError::IslandInsertOutOfRange { at: 9, len: 2 })
2062        );
2063
2064        rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
2065            at: 1,
2066            island: image("isl-a"),
2067        }]))
2068        .unwrap();
2069        assert_eq!(
2070            rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
2071                at: 0,
2072                island: image("isl-a"),
2073            }])),
2074            Err(ApplyError::IslandIdCollision { id: "isl-a".into() })
2075        );
2076    }
2077
2078    /// A block island in one bundle, which is what the stage order buys: the
2079    /// delta opens the line, the island op fills it, `SetKind` tags it. Nothing
2080    /// here falls back to a whole-value install, so the field's anchors stay.
2081    #[test]
2082    fn block_island_lands_in_one_bundle() {
2083        let mut rt = from_markdown("intro").unwrap();
2084        rt.apply_mark_ops(&[MarkOp::Add {
2085            start: 0,
2086            end: 5,
2087            kind: MarkKind::Anchor { id: "c1".into() },
2088        }])
2089        .unwrap();
2090
2091        rt.apply_field_change(&ChangeBundle {
2092            delta: diff("intro", "intro\n"),
2093            island_ops: vec![IslandOp::Insert {
2094                at: 6,
2095                island: Island::new("isl-t".into(), "table".into())
2096                    .with_props(table_props("H", "a")),
2097            }],
2098            line_ops: vec![LineOp::SetKind {
2099                line: 1,
2100                kind: LineKind::Island,
2101            }],
2102            ..Default::default()
2103        })
2104        .unwrap();
2105
2106        assert_eq!(rt.text, format!("intro\n{ISLAND_SLOT}"));
2107        assert_eq!(rt.lines[1].kind, LineKind::Island);
2108        assert_eq!(rt.validate(), Ok(()));
2109        assert!(rt
2110            .marks
2111            .iter()
2112            .any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")));
2113        assert!(
2114            crate::export::to_markdown(&rt).contains("| H |"),
2115            "the block island projects as a pipe table"
2116        );
2117    }
2118
2119    /// A bundle whose island op fails commits none of its earlier stages.
2120    #[test]
2121    fn island_op_failure_leaves_the_content_untouched() {
2122        let mut rt = from_markdown("ab").unwrap();
2123        let before = rt.clone();
2124        let err = rt.apply_field_change(&ChangeBundle {
2125            delta: diff("ab", "aXb"),
2126            island_ops: vec![IslandOp::Set {
2127                island: Island::new("isl-nope".into(), "image".into()),
2128            }],
2129            ..Default::default()
2130        });
2131        assert!(matches!(err, Err(ApplyError::UnknownIslandId { .. })));
2132        assert_eq!(rt, before, "failed bundle must not mutate the content");
2133    }
2134
2135    #[test]
2136    fn apply_field_change_bundle_order() {
2137        let mut rt = from_markdown("abc").unwrap();
2138        let d = diff("abc", "abXc");
2139        rt.apply_field_change(&mark_bundle(
2140            d,
2141            vec![MarkOp::Add {
2142                start: 3,
2143                end: 4,
2144                kind: MarkKind::Strong,
2145            }],
2146        ))
2147        .unwrap();
2148        let strong = rt
2149            .marks
2150            .iter()
2151            .find(|m| matches!(m.kind, MarkKind::Strong))
2152            .unwrap();
2153        assert_eq!((strong.start, strong.end), (3, 4));
2154        assert_eq!(rt.text, "abXc");
2155    }
2156
2157    #[test]
2158    fn apply_field_change_is_all_or_nothing() {
2159        // A bundle whose text delta and first mark op succeed but whose second
2160        // mark op is out of range must leave the content exactly as it was: the
2161        // successful earlier stages do not partially commit.
2162        let mut rt = from_markdown("abc").unwrap();
2163        let before = rt.clone();
2164        let d = diff("abc", "abXc");
2165        let err = rt.apply_field_change(&mark_bundle(
2166            d,
2167            vec![
2168                MarkOp::Add {
2169                    start: 0,
2170                    end: 2,
2171                    kind: MarkKind::Strong,
2172                },
2173                MarkOp::Add {
2174                    start: 99,
2175                    end: 100,
2176                    kind: MarkKind::Emph,
2177                },
2178            ],
2179        ));
2180        assert!(matches!(err, Err(ApplyError::MarkOutOfRange { .. })));
2181        assert_eq!(rt, before, "failed bundle must not mutate the content");
2182    }
2183
2184    /// `add` of an anchor rejects a live id collision and the empty id, but
2185    /// re-adds an id freed by an earlier `RemoveAnchor` in the same bundle.
2186    #[test]
2187    fn add_anchor_id_uniqueness() {
2188        let anchor = |id: &str| MarkKind::Anchor { id: id.into() };
2189        let add = |start, end, id: &str| MarkOp::Add {
2190            start,
2191            end,
2192            kind: anchor(id),
2193        };
2194
2195        let noop = || diff("abcd", "abcd");
2196
2197        // First anchor lands; a second `add` of the same id is a collision.
2198        let mut rt = from_markdown("abcd").unwrap();
2199        rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "x")]))
2200            .unwrap();
2201        assert_eq!(
2202            rt.apply_field_change(&mark_bundle(noop(), vec![add(2, 4, "x")])),
2203            Err(ApplyError::AnchorIdCollision { id: "x".into() })
2204        );
2205
2206        // The empty id is refused.
2207        let mut rt = from_markdown("abcd").unwrap();
2208        assert_eq!(
2209            rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "")])),
2210            Err(ApplyError::EmptyAnchorId)
2211        );
2212
2213        // Remove-then-add of the same id in one bundle is allowed: ops apply in
2214        // sequence, so the id is free by the time the `add` runs.
2215        let mut rt = from_markdown("abcd").unwrap();
2216        rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "x")]))
2217            .unwrap();
2218        rt.apply_field_change(&mark_bundle(
2219            noop(),
2220            vec![MarkOp::RemoveAnchor { id: "x".into() }, add(2, 4, "x")],
2221        ))
2222        .unwrap();
2223        let anchors: Vec<_> = rt
2224            .marks
2225            .iter()
2226            .filter(|m| matches!(m.kind, MarkKind::Anchor { .. }))
2227            .collect();
2228        assert_eq!(anchors.len(), 1);
2229        assert_eq!((anchors[0].start, anchors[0].end), (2, 4));
2230    }
2231
2232    // ── sync_lines_for_delta characterization ───────────────────────────────
2233    //
2234    // Pin the observable behavior of the line-sync walk: retain/insert/delete
2235    // interleavings, the split template-clone rule, and the malformed-content
2236    // guards: against a silent change to its internals.
2237
2238    /// A `Heading{level}` line, its level a visible tag so a test can trace
2239    /// which original line landed where; `continues` distinguishes a clone.
2240    fn tag_line(level: u8, continues: bool) -> Line {
2241        Line {
2242            kind: LineKind::Heading { level },
2243            containers: Vec::new(),
2244            continues,
2245        }
2246    }
2247
2248    /// `(tag, continues)` per line: `Heading{level}` reads its level, `Para` is
2249    /// tag 0 (the default line), any other kind is 255.
2250    fn tags(lines: &[Line]) -> Vec<(u8, bool)> {
2251        lines
2252            .iter()
2253            .map(|l| match l.kind {
2254                LineKind::Heading { level } => (level, l.continues),
2255                LineKind::Para => (0, l.continues),
2256                _ => (255, l.continues),
2257            })
2258            .collect()
2259    }
2260
2261    #[test]
2262    fn sync_lines_retain_only_is_identity() {
2263        let old_chars: Vec<char> = "a\nb\nc".chars().collect();
2264        let lines = vec![tag_line(1, false), tag_line(2, false), tag_line(3, false)];
2265        let d = Delta {
2266            ops: vec![Op::Retain(5)],
2267        };
2268        assert_eq!(sync_lines_for_delta(&old_chars, lines.clone(), &d), lines);
2269    }
2270
2271    #[test]
2272    fn sync_lines_insert_newline_clones_split_line_and_clears_continues() {
2273        // Split line 1 ("bc") mid-line: the first half stays the original line
2274        // (keeps kind, containers, and its `continues: true`); the second half
2275        // is a clone of it with `continues` forced false.
2276        let old_chars: Vec<char> = "a\nbc".chars().collect();
2277        let l1 = Line {
2278            kind: LineKind::Heading { level: 5 },
2279            containers: vec![Container::Quote],
2280            continues: true,
2281        };
2282        let lines = vec![tag_line(1, false), l1.clone()];
2283        // Retain(3)[a\nb] moves to line 1; Insert("\n") splits it; Retain(1)[c].
2284        let d = Delta {
2285            ops: vec![Op::Retain(3), Op::Insert("\n".into()), Op::Retain(1)],
2286        };
2287        let out = sync_lines_for_delta(&old_chars, lines, &d);
2288        assert_eq!(out.len(), 3);
2289        assert_eq!(out[1], l1, "first half is the untouched original line");
2290        assert_eq!(out[2].kind, LineKind::Heading { level: 5 });
2291        assert_eq!(out[2].containers, vec![Container::Quote]);
2292        assert!(!out[2].continues, "the split clone starts a new block");
2293    }
2294
2295    #[test]
2296    fn sync_lines_delete_newline_drops_following_line() {
2297        // Delete the first '\n' of "a\nb\nc": lines 0 and 1 merge, dropping line
2298        // 1; the current line (0) and line 2 survive.
2299        let old_chars: Vec<char> = "a\nb\nc".chars().collect();
2300        let lines = vec![tag_line(1, false), tag_line(2, false), tag_line(3, false)];
2301        let d = Delta {
2302            ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(3)],
2303        };
2304        let out = sync_lines_for_delta(&old_chars, lines, &d);
2305        assert_eq!(tags(&out), vec![(1, false), (3, false)]);
2306    }
2307
2308    #[test]
2309    fn sync_lines_delete_trailing_newline_without_following_line_is_guarded() {
2310        // Malformed content: text "a\n" is two segments but `lines` has one
2311        // entry. Deleting the '\n' when `line_idx + 1` is out of bounds removes
2312        // nothing (the guard), leaving the single line intact.
2313        let old_chars: Vec<char> = "a\n".chars().collect();
2314        let lines = vec![tag_line(1, false)];
2315        let d = Delta {
2316            ops: vec![Op::Retain(1), Op::Delete(1)],
2317        };
2318        let out = sync_lines_for_delta(&old_chars, lines, &d);
2319        assert_eq!(tags(&out), vec![(1, false)]);
2320    }
2321
2322    #[test]
2323    fn sync_lines_stops_at_end_of_old_chars() {
2324        // A retain running past the end of old_chars stops at the end rather
2325        // than indexing out of bounds (the `old >= old_chars.len()` guard).
2326        let old_chars: Vec<char> = "a\nb".chars().collect();
2327        let lines = vec![tag_line(1, false), tag_line(2, false)];
2328        let d = Delta {
2329            ops: vec![Op::Retain(99)],
2330        };
2331        assert_eq!(sync_lines_for_delta(&old_chars, lines.clone(), &d), lines);
2332    }
2333
2334    #[test]
2335    fn sync_lines_insert_two_newlines_adds_two_clones() {
2336        // Inserting "\n\n" mid-line adds two lines, each carrying the split
2337        // line's kind and containers with `continues: false`.
2338        let old_chars: Vec<char> = "abc".chars().collect();
2339        let src = Line {
2340            kind: LineKind::Heading { level: 7 },
2341            containers: vec![Container::Quote],
2342            continues: false,
2343        };
2344        let d = Delta {
2345            ops: vec![Op::Retain(1), Op::Insert("\n\n".into()), Op::Retain(2)],
2346        };
2347        let out = sync_lines_for_delta(&old_chars, vec![src], &d);
2348        assert_eq!(out.len(), 3);
2349        for l in &out {
2350            assert_eq!(l.kind, LineKind::Heading { level: 7 });
2351            assert_eq!(l.containers, vec![Container::Quote]);
2352            assert!(!l.continues);
2353        }
2354    }
2355
2356    // ── line-op mark remap + terminal-normalize collapse ────────────────────
2357
2358    #[test]
2359    fn split_line_rebases_mark_across_the_split_point() {
2360        // A strong mark spanning the split point grows by the inserted `\n`
2361        // rather than staying at its old coordinates. "abcd", strong[1..3)
2362        // ("bc"); split at 2 → "ab\ncd"; the mark must still cover "b"+"c",
2363        // i.e. [1..4) over "ab\ncd".
2364        let mut rt = from_markdown("abcd").unwrap();
2365        rt.apply_mark_ops(&[MarkOp::Add {
2366            start: 1,
2367            end: 3,
2368            kind: MarkKind::Strong,
2369        }])
2370        .unwrap();
2371        rt.apply_line_ops(&[LineOp::Split { at: 2 }]).unwrap();
2372        assert_eq!(rt.text, "ab\ncd");
2373        let strong: Vec<_> = rt
2374            .marks
2375            .iter()
2376            .filter(|m| matches!(m.kind, MarkKind::Strong))
2377            .map(|m| (m.start, m.end))
2378            .collect();
2379        // [1..4) spans "b\nc"; normalize keeps the interior `\n` (a mark may
2380        // legitimately span lines), trimming only leading/trailing boundaries.
2381        assert_eq!(strong, vec![(1, 4)]);
2382        assert_eq!(rt.validate(), Ok(()));
2383    }
2384
2385    #[test]
2386    fn join_line_rebases_marks_to_final_text_coordinates() {
2387        // The issue's concrete drift case: "ab\ncd", strong[2..4) (over "\nc").
2388        // Joining line 0 removes the `\n`; the remap + terminal normalize must
2389        // land strong on "c" (coordinate [2..3) over "abcd") not on "d"
2390        // (the un-remapped-mark bug) nor on "cd".
2391        let mut rt = from_markdown("ab").unwrap();
2392        rt.apply_text_delta(&diff("ab", "ab\ncd")).unwrap();
2393        rt.marks.push(Mark {
2394            start: 2,
2395            end: 4,
2396            kind: MarkKind::Strong,
2397        });
2398        rt.normalize();
2399        // Post-normalize the `\n` edge trims to [3..4) ("c"); either way the
2400        // join must converge to strong on "c".
2401        rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
2402        assert_eq!(rt.text, "abcd");
2403        let strong: Vec<_> = rt
2404            .marks
2405            .iter()
2406            .filter(|m| matches!(m.kind, MarkKind::Strong))
2407            .map(|m| (m.start, m.end))
2408            .collect();
2409        assert_eq!(strong, vec![(2, 3)], "strong lands on 'c', not 'd' or 'cd'");
2410        assert_eq!(rt.validate(), Ok(()));
2411    }
2412
2413    #[test]
2414    fn field_change_terminal_normalize_matches_per_stage_normalize() {
2415        // The collapse proof obligation: a bundle applied through
2416        // `apply_field_change` (one terminal normalize) must equal applying the
2417        // same stages each with its own normalize (the public wrappers). The
2418        // remap through split/join is what makes the two converge.
2419        let start = from_markdown("hello world").unwrap();
2420        let text_delta = diff("hello world", "hello brave world");
2421        let line_ops = vec![LineOp::Split { at: 5 }]; // after "hello"
2422        let mark_ops = vec![MarkOp::Add {
2423            start: 0,
2424            end: 5,
2425            kind: MarkKind::Strong,
2426        }];
2427
2428        let mut bundled = start.clone();
2429        bundled
2430            .apply_field_change(&ChangeBundle {
2431                delta: text_delta.clone(),
2432                line_ops: line_ops.clone(),
2433                mark_ops: mark_ops.clone(),
2434                ..Default::default()
2435            })
2436            .unwrap();
2437
2438        let mut staged = start;
2439        staged.apply_text_delta(&text_delta).unwrap();
2440        staged.apply_line_ops(&line_ops).unwrap();
2441        staged.apply_mark_ops(&mark_ops).unwrap();
2442
2443        assert_eq!(bundled, staged, "terminal normalize diverged from per-stage");
2444        assert_eq!(bundled.validate(), Ok(()));
2445    }
2446
2447    #[test]
2448    fn sync_lines_select_all_delete_collapses_to_first_line() {
2449        // The motivating case: deleting a whole
2450        // multi-line body drops every line but the first (each deleted '\n'
2451        // merges the next line away).
2452        let text: String = (0..50).map(|i| format!("line{i}\n")).collect();
2453        let old_chars: Vec<char> = text.chars().collect();
2454        let lines: Vec<Line> = (0..=50).map(|i| tag_line((i % 200) as u8, false)).collect();
2455        assert_eq!(lines.len(), old_chars.iter().filter(|&&c| c == '\n').count() + 1);
2456        let d = Delta {
2457            ops: vec![Op::Delete(old_chars.len())],
2458        };
2459        let out = sync_lines_for_delta(&old_chars, lines, &d);
2460        assert_eq!(tags(&out), vec![(0, false)], "only the first line survives");
2461    }
2462
2463    #[test]
2464    fn sync_lines_insert_newline_past_end_appends_default() {
2465        // Malformed content: after a retain walks past the sole line (line_idx ==
2466        // lines.len()), an inserted '\n' has no line to clone and appends a
2467        // default Para.
2468        let old_chars: Vec<char> = "a\n".chars().collect();
2469        let lines = vec![tag_line(1, false)];
2470        // Retain(2)[a\n] moves line_idx to 1 (== lines.len()); Insert("\n").
2471        let d = Delta {
2472            ops: vec![Op::Retain(2), Op::Insert("\n".into())],
2473        };
2474        let out = sync_lines_for_delta(&old_chars, lines, &d);
2475        assert_eq!(out.len(), 2);
2476        assert_eq!(tags(&out)[0], (1, false));
2477        assert_eq!(out[1].kind, LineKind::Para);
2478        assert!(out[1].containers.is_empty());
2479        assert!(!out[1].continues);
2480    }
2481}