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