Skip to main content

quillmark_content/
ops.rs

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