Skip to main content

quillmark_content/
ops.rs

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