Skip to main content

common/
format_runs.rs

1//! Per-block character formatting as sorted, non-overlapping byte spans.
2//!
3//! Each block carries a `Vec<FormatRun>` (formatting) and a
4//! `Vec<ImageAnchor>` (image positions). The block's `plain_text` is
5//! the authoritative character source for byte offsets used by both.
6//! Replaces the pre-Phase-1 model where every formatted run and every
7//! inline image was a row in the now-deleted `inline_elements` entity
8//! table; the [`InlineSegment`] type in this module is a transient
9//! view synthesized from `(plain_text, format_runs, block_images)`
10//! for readers (export, fragments, cursor) that still consume a
11//! per-segment shape.
12//!
13//! Invariants are documented on [`FormatRun`] and enforced by
14//! [`debug_assert_well_formed`] and by [`splice_range`] / [`shift_after`]
15//! which rebuild the run list while preserving them.
16//!
17//! **In a release build a `debug_assert!` is not enforcement.** It is compiled out, so
18//! a contract violation there produced a malformed run list *silently* — and autosave
19//! wrote that corruption to the writer's file seconds later. The bulk-edit path
20//! therefore uses the checked siblings, which report instead of assert:
21//! [`check_well_formed`], [`try_splice_range`] and [`shift_runs_for_replace`], all
22//! returning [`FormatRunError`].
23
24use crate::entities::{CharVerticalAlignment, UnderlineStyle};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28/// A violation of the format-run invariants, reported instead of asserted.
29///
30/// The invariants used to be guarded only by `debug_assert!`, which is **compiled out
31/// of release builds** — so a contract violation silently produced a malformed run list
32/// in a shipped binary, and (with autosave) that corruption became the file's new truth
33/// within seconds. Anything on the *replace* path returns this instead, so the use case
34/// can refuse the edit and say why rather than quietly mangle a writer's formatting.
35#[derive(Debug, Clone, PartialEq, Eq, Error)]
36pub enum FormatRunError {
37    #[error("byte range {start}..{end} is reversed")]
38    ReversedRange { start: u32, end: u32 },
39
40    #[error(
41        "replacement run {run_start}..{run_end} falls outside the spliced range \
42         {range_start}..{range_end}"
43    )]
44    ReplacementOutsideRange {
45        run_start: u32,
46        run_end: u32,
47        range_start: u32,
48        range_end: u32,
49    },
50
51    #[error("run {start}..{end} is empty or reversed")]
52    EmptyRun { start: u32, end: u32 },
53
54    #[error("runs overlap or are out of order at index {index}: {left:?} then {right:?}")]
55    RunsOverlap {
56        index: usize,
57        left: Box<FormatRun>,
58        right: Box<FormatRun>,
59    },
60
61    #[error("adjacent runs with identical formatting were left uncoalesced at index {index}")]
62    RunsNotCoalesced { index: usize },
63
64    #[error("run {start}..{end} runs past the end of the block's {text_len} bytes")]
65    RunPastEndOfBlock {
66        start: u32,
67        end: u32,
68        text_len: usize,
69    },
70}
71
72/// What the replacement text wears when it overwrites formatted text.
73///
74/// Before this existed the behaviour was **emergent, not chosen**: a replace was a
75/// delete followed by an insert, and `shift_runs_for_insert` hardcodes "the inserted
76/// text inherits whatever run ends at the insertion point". That silently destroys
77/// formatting — renaming a character whose name reads `Auré**lien**` dropped the bold
78/// entirely, and not one test in the repo covered it.
79///
80/// The old behaviour is still the default (and is byte-for-byte identical); it is now
81/// simply one option among four, and the caller has to look at it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83pub enum ReplaceFormatPolicy {
84    /// The replacement inherits the format of the run that straddles the start of the
85    /// replaced range or ends exactly on it (precisely: `byte_start < start &&
86    /// byte_end >= start`); otherwise it is unformatted. A run beginning *exactly* at
87    /// the start never contributes.
88    ///
89    /// This is the Qt / ProseMirror insertion convention, and it is what
90    /// delete-then-insert has always produced — byte for byte, which
91    /// `inherit_preceding_matches_the_historical_delete_then_insert` pins *differentially*
92    /// (it runs the historical primitives and compares, rather than trusting a
93    /// transcription of them).
94    #[default]
95    InheritPreceding,
96
97    /// If a **single run covers the whole replaced range**, the replacement keeps that
98    /// run's format; otherwise fall back to [`Self::InheritPreceding`]. "Rename a name
99    /// that was entirely bold and it stays bold" — without guessing when the range is
100    /// formatted unevenly.
101    PreserveIfFullyCovered,
102
103    /// The replacement takes the format that covered the **most bytes** of the replaced
104    /// range. Unformatted gaps count as a candidate, so a mostly-plain range stays
105    /// plain; ties go to the *formatted* run, because a tie means the writer's
106    /// formatting covered at least as much as its absence and dropping it is the
107    /// destructive answer.
108    KeepDominantRun,
109
110    /// The replacement carries no formatting at all.
111    PreserveNothing,
112}
113
114/// Content type for an inline segment: text, image, or empty.
115#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
116pub enum InlineContent {
117    #[default]
118    Empty,
119    Text(String),
120    Image {
121        name: String,
122        width: i64,
123        height: i64,
124        quality: i64,
125    },
126}
127
128/// A lean view type representing one inline segment (text or image) with its
129/// associated formatting. Used by readers (export, fragments, cursor) to
130/// consume per-segment data synthesized from `(plain_text, format_runs,
131/// block_images)` via [`crate::format_runs_query::inline_segments_for_block`].
132/// Never stored — synthesized on demand.
133///
134/// The `fmt_*` field names match those on `Block` and on `FragmentElement`
135/// so readers can copy fields verbatim across the three types.
136#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub struct InlineSegment {
138    pub content: InlineContent,
139    pub fmt_font_family: Option<String>,
140    pub fmt_font_point_size: Option<i64>,
141    pub fmt_font_weight: Option<i64>,
142    pub fmt_font_bold: Option<bool>,
143    pub fmt_font_italic: Option<bool>,
144    pub fmt_font_underline: Option<bool>,
145    pub fmt_font_overline: Option<bool>,
146    pub fmt_font_strikeout: Option<bool>,
147    pub fmt_letter_spacing: Option<i64>,
148    pub fmt_word_spacing: Option<i64>,
149    pub fmt_anchor_href: Option<String>,
150    pub fmt_anchor_names: Vec<String>,
151    pub fmt_is_anchor: Option<bool>,
152    pub fmt_tooltip: Option<String>,
153    pub fmt_underline_style: Option<UnderlineStyle>,
154    pub fmt_vertical_alignment: Option<CharVerticalAlignment>,
155}
156
157/// Character-level formatting for a contiguous byte span. One per
158/// [`FormatRun`]; one per [`ImageAnchor`]. Fields mirror the `fmt_*`
159/// set on [`InlineSegment`] and on `FragmentElement` so values copy
160/// across types verbatim.
161#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162pub struct CharacterFormat {
163    pub font_family: Option<String>,
164    pub font_point_size: Option<i64>,
165    pub font_weight: Option<i64>,
166    pub font_bold: Option<bool>,
167    pub font_italic: Option<bool>,
168    pub font_underline: Option<bool>,
169    pub font_overline: Option<bool>,
170    pub font_strikeout: Option<bool>,
171    pub letter_spacing: Option<i64>,
172    pub word_spacing: Option<i64>,
173    pub anchor_href: Option<String>,
174    pub anchor_names: Vec<String>,
175    pub is_anchor: Option<bool>,
176    pub tooltip: Option<String>,
177    pub underline_style: Option<UnderlineStyle>,
178    pub vertical_alignment: Option<CharVerticalAlignment>,
179}
180
181/// One run of identical character formatting inside a block. Byte offsets
182/// are relative to the block's `plain_text` (Phase 1) or to the block's
183/// rope range (Phase 2).
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct FormatRun {
186    pub byte_start: u32,
187    pub byte_end: u32,
188    pub format: CharacterFormat,
189}
190
191/// An image embedded at a specific byte position inside a block. In
192/// Phase 1 the byte position is an index into the block's `plain_text`;
193/// in Phase 2 it points at the U+FFFC sentinel character in the rope.
194///
195/// Images carry their own [`CharacterFormat`] because vertical alignment
196/// and anchor metadata apply per inline run.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct ImageAnchor {
199    pub byte_offset: u32,
200    pub name: String,
201    pub width: i64,
202    pub height: i64,
203    pub quality: i64,
204    pub format: CharacterFormat,
205}
206
207/// Debug-only invariant check. Run from `debug_assert!` callsites in
208/// the use cases that mutate format runs. Cheap: O(n) where n is the
209/// run count (typically < 100 per block in real prose).
210///
211/// # Invariants
212/// 1. Runs are sorted by `byte_start` ascending.
213/// 2. Each run has `byte_start < byte_end`.
214/// 3. Runs are non-overlapping: `runs[i].byte_end <= runs[i+1].byte_start`.
215/// 4. The last run's `byte_end` does not exceed `block_text_len`.
216/// 5. Adjacent runs with identical format are coalesced (no two
217///    consecutive runs satisfy `byte_end == next.byte_start &&
218///    format == next.format`).
219pub fn debug_assert_well_formed(runs: &[FormatRun], block_text_len: usize) {
220    // Only pay the O(n) walk where the assertion would actually fire.
221    if cfg!(debug_assertions)
222        && let Err(e) = check_well_formed(runs, block_text_len)
223    {
224        debug_assert!(false, "format runs are malformed: {e}");
225    }
226}
227
228/// The same invariants as [`debug_assert_well_formed`], but **reported rather than
229/// asserted** — so a caller that must not corrupt a block can refuse the edit.
230///
231/// This exists because `debug_assert!` is compiled out of release builds: a malformed
232/// run list produced in a shipped binary went completely undetected, and autosave wrote
233/// it to disk seconds later. The replace path calls this and propagates the error.
234pub fn check_well_formed(runs: &[FormatRun], block_text_len: usize) -> Result<(), FormatRunError> {
235    if runs.is_empty() {
236        return Ok(());
237    }
238    for run in runs {
239        if run.byte_start >= run.byte_end {
240            return Err(FormatRunError::EmptyRun {
241                start: run.byte_start,
242                end: run.byte_end,
243            });
244        }
245    }
246    for i in 0..runs.len() - 1 {
247        if runs[i].byte_end > runs[i + 1].byte_start {
248            return Err(FormatRunError::RunsOverlap {
249                index: i,
250                left: Box::new(runs[i].clone()),
251                right: Box::new(runs[i + 1].clone()),
252            });
253        }
254        if runs[i].byte_end == runs[i + 1].byte_start && runs[i].format == runs[i + 1].format {
255            return Err(FormatRunError::RunsNotCoalesced { index: i });
256        }
257    }
258    let last = runs.last().expect("non-empty");
259    if last.byte_end as usize > block_text_len {
260        return Err(FormatRunError::RunPastEndOfBlock {
261            start: last.byte_start,
262            end: last.byte_end,
263            text_len: block_text_len,
264        });
265    }
266    Ok(())
267}
268
269/// Merge adjacent runs that have identical formatting. O(n).
270pub fn coalesce_in_place(runs: &mut Vec<FormatRun>) {
271    if runs.len() < 2 {
272        return;
273    }
274    let mut write = 0usize;
275    for read in 1..runs.len() {
276        if runs[write].byte_end == runs[read].byte_start && runs[write].format == runs[read].format
277        {
278            runs[write].byte_end = runs[read].byte_end;
279        } else {
280            write += 1;
281            if write != read {
282                runs[write] = runs[read].clone();
283            }
284        }
285    }
286    runs.truncate(write + 1);
287}
288
289/// Replace the runs covering `range` with `replacement`, preserving the
290/// invariants. Runs that straddle the range boundary are clipped on
291/// either side; runs fully contained are removed.
292///
293/// The replacement byte ranges must lie within `range` and themselves
294/// be well-formed (sorted, non-overlapping). The function does NOT
295/// shift bytes after `range.end` — callers wanting to splice in a
296/// different-length text must call [`shift_after`] first or after,
297/// depending on whether the text length is changing.
298pub fn splice_range(
299    runs: &mut Vec<FormatRun>,
300    range: std::ops::Range<u32>,
301    replacement: Vec<FormatRun>,
302) {
303    if let Err(e) = try_splice_range(runs, range, replacement) {
304        // Previously this was a bare `debug_assert!` — compiled OUT of release, where a
305        // contract violation therefore went on to build a malformed run list and corrupt
306        // the block's formatting silently. Keep the loud failure in debug; in release,
307        // refuse the splice and leave `runs` untouched rather than mangle it.
308        //
309        // No existing caller can reach this: every one either builds a replacement that
310        // spans exactly the range, or goes through `shift_runs_for_delete`, which
311        // early-returns on an inverted range. It is a net, not a behaviour change.
312        debug_assert!(false, "splice_range contract violated: {e}");
313    }
314}
315
316/// [`splice_range`], but the contract is **checked and reported** instead of asserted.
317///
318/// `runs` is left completely untouched when this returns `Err` — validation happens
319/// before any mutation, so a rejected splice cannot half-apply.
320pub fn try_splice_range(
321    runs: &mut Vec<FormatRun>,
322    range: std::ops::Range<u32>,
323    replacement: Vec<FormatRun>,
324) -> Result<(), FormatRunError> {
325    if range.start > range.end {
326        return Err(FormatRunError::ReversedRange {
327            start: range.start,
328            end: range.end,
329        });
330    }
331    for r in &replacement {
332        if r.byte_start >= r.byte_end {
333            return Err(FormatRunError::EmptyRun {
334                start: r.byte_start,
335                end: r.byte_end,
336            });
337        }
338        if r.byte_start < range.start || r.byte_end > range.end {
339            return Err(FormatRunError::ReplacementOutsideRange {
340                run_start: r.byte_start,
341                run_end: r.byte_end,
342                range_start: range.start,
343                range_end: range.end,
344            });
345        }
346    }
347    for i in 1..replacement.len() {
348        if replacement[i - 1].byte_end > replacement[i].byte_start {
349            return Err(FormatRunError::RunsOverlap {
350                index: i - 1,
351                left: Box::new(replacement[i - 1].clone()),
352                right: Box::new(replacement[i].clone()),
353            });
354        }
355    }
356
357    let mut result: Vec<FormatRun> = Vec::with_capacity(runs.len() + replacement.len());
358
359    // Keep / clip everything strictly before range.start.
360    for run in runs.iter() {
361        if run.byte_end <= range.start {
362            result.push(run.clone());
363        } else if run.byte_start < range.start {
364            // Run straddles range.start: keep the left part.
365            result.push(FormatRun {
366                byte_start: run.byte_start,
367                byte_end: range.start,
368                format: run.format.clone(),
369            });
370        }
371    }
372
373    // Insert the replacement runs.
374    result.extend(replacement);
375
376    // Keep / clip everything starting at or after range.end.
377    for run in runs.iter() {
378        if run.byte_start >= range.end {
379            result.push(run.clone());
380        } else if run.byte_end > range.end {
381            // Run straddles range.end: keep the right part.
382            result.push(FormatRun {
383                byte_start: range.end,
384                byte_end: run.byte_end,
385                format: run.format.clone(),
386            });
387        }
388    }
389
390    coalesce_in_place(&mut result);
391    *runs = result;
392    Ok(())
393}
394
395/// Capture the slice of `runs` that intersects `[start..end)`, clipped
396/// to those bounds. Used by hand-rolled-inverse undo for format-only
397/// edits: callers capture this BEFORE calling [`splice_range`], and
398/// on undo splice the captured runs back into the same byte range to
399/// restore the prior state without paying the cost of a full
400/// `RopeStoreSnapshot`.
401///
402/// Gaps in the original runs (positions inside `[start..end)` with no
403/// formatting) become gaps in the captured output too — the undo
404/// splice preserves them faithfully.
405pub fn capture_runs_in_range(runs: &[FormatRun], start: u32, end: u32) -> Vec<FormatRun> {
406    let mut out = Vec::new();
407    for run in runs {
408        if run.byte_end <= start || run.byte_start >= end {
409            continue;
410        }
411        let clipped_start = std::cmp::max(run.byte_start, start);
412        let clipped_end = std::cmp::min(run.byte_end, end);
413        if clipped_start < clipped_end {
414            out.push(FormatRun {
415                byte_start: clipped_start,
416                byte_end: clipped_end,
417                format: run.format.clone(),
418            });
419        }
420    }
421    out
422}
423
424/// Capture the `(byte_offset, format)` pairs for every image anchor
425/// inside `[start..end)`. Used together with [`capture_runs_in_range`]
426/// by hand-rolled-inverse undo for format-only edits.
427pub fn capture_image_formats_in_range(
428    images: &[ImageAnchor],
429    start: u32,
430    end: u32,
431) -> Vec<(u32, CharacterFormat)> {
432    let mut out = Vec::new();
433    for img in images {
434        if img.byte_offset >= start && img.byte_offset < end {
435            out.push((img.byte_offset, img.format.clone()));
436        }
437    }
438    out
439}
440
441/// Shift the byte offsets of every run whose `byte_start >= threshold`
442/// by `delta`. Used after a text insert/delete to keep downstream runs
443/// in sync with the new block text. Runs strictly before the threshold
444/// are unaffected; runs that straddle the threshold are left alone
445/// (the caller should have spliced them first).
446///
447/// Panics in debug mode if `delta` would underflow a run's offset.
448pub fn shift_after(runs: &mut [FormatRun], threshold: u32, delta: i32) {
449    for run in runs.iter_mut() {
450        if run.byte_start >= threshold {
451            let new_start = (run.byte_start as i64) + (delta as i64);
452            let new_end = (run.byte_end as i64) + (delta as i64);
453            debug_assert!(new_start >= 0 && new_end >= new_start);
454            run.byte_start = new_start as u32;
455            run.byte_end = new_end as u32;
456        }
457    }
458}
459
460/// Synthesize a stable per-fragment id from a block id and byte offset
461/// within that block. Populates the `element_id` field in
462/// `FragmentContent::{Text, Image}` (the public layout-engine type),
463/// giving callers a stable handle across renders even though the
464/// underlying [`InlineSegment`]s are never stored. Two segments at the
465/// same `(block_id, byte_start)` always produce the same id; a segment
466/// that moves to a new byte_start (e.g. due to an insert upstream)
467/// gets a new id.
468///
469/// Bit layout (u64): bit 62 = synth tag (so synthesized ids never
470/// collide with real entity ids issued by the store's counter, which
471/// start at 1 and grow upward). Bits 32..62 = block id (1 billion
472/// blocks per document, 30 bits). Bottom 32 bits = byte offset (4 GB
473/// per block). The top bit stays zero so the value fits in positive
474/// i64 range — public DTOs expose element_id as i64.
475pub fn synth_element_id(block_id: u64, byte_start: u32) -> u64 {
476    const SYNTH_TAG: u64 = 0x4000_0000_0000_0000;
477    SYNTH_TAG | ((block_id & 0x3FFF_FFFF) << 32) | (byte_start as u64)
478}
479
480/// Same as `shift_after` for image anchors. Anchors AT the threshold are
481/// shifted (treated as part of the inserted region's right side).
482pub fn shift_images_after(images: &mut [ImageAnchor], threshold: u32, delta: i32) {
483    for img in images.iter_mut() {
484        if img.byte_offset >= threshold {
485            let new_off = (img.byte_offset as i64) + (delta as i64);
486            debug_assert!(new_off >= 0);
487            img.byte_offset = new_off as u32;
488        }
489    }
490}
491
492// ─────────────────────────────────────────────────────────────────────
493// Composite helpers used by writer use cases. These keep the per-block
494// run / image vectors well-formed under insert / delete / split.
495// ─────────────────────────────────────────────────────────────────────
496
497/// Apply an "insert `inserted_bytes` of text at `byte_offset`" mutation
498/// to a block's runs in place. Runs strictly before the offset are
499/// unchanged; runs strictly after are shifted by +inserted_bytes; runs
500/// that straddle the offset are extended (the inserted text inherits
501/// the surrounding run's format — Qt / ProseMirror convention).
502pub fn shift_runs_for_insert(runs: &mut [FormatRun], byte_offset: u32, inserted_bytes: u32) {
503    if inserted_bytes == 0 {
504        return;
505    }
506    for run in runs.iter_mut() {
507        if run.byte_start >= byte_offset {
508            run.byte_start += inserted_bytes;
509            run.byte_end += inserted_bytes;
510        } else if run.byte_end >= byte_offset {
511            // Run straddles the insertion point, or its right edge sits
512            // exactly on it. In both cases the inserted text inherits
513            // this run's format (Qt convention).
514            run.byte_end += inserted_bytes;
515        }
516    }
517}
518
519/// Apply a "delete byte range `[byte_start..byte_end)`" mutation to a
520/// block's runs. Splices the range with empty replacement (clipping
521/// straddling runs) and shifts everything past `byte_end` back by the
522/// deleted length. Adjacent runs that end up equal-format are coalesced.
523pub fn shift_runs_for_delete(runs: &mut Vec<FormatRun>, byte_start: u32, byte_end: u32) {
524    if byte_end <= byte_start {
525        return;
526    }
527    splice_range(runs, byte_start..byte_end, Vec::new());
528    let delta = (byte_end - byte_start) as i32;
529    shift_after(runs, byte_end, -delta);
530    // The shift can make a left-clipped run abut a shifted trailing run
531    // with identical format; coalesce once more to restore the invariant.
532    coalesce_in_place(runs);
533}
534
535/// Apply a "replace byte range `[byte_start..byte_end)` with `replacement_bytes` bytes"
536/// mutation to a block's runs, choosing explicitly what the replacement wears.
537///
538/// This is the one edit where the old delete-then-insert composition quietly lost a
539/// writer's formatting: replacing `Auré**lien**` deletes both runs under the range and
540/// then lets the replacement inherit whatever preceded it, so the bold is gone. That
541/// behaviour is still available — and is still the default — but it is now a *decision*
542/// ([`ReplaceFormatPolicy`]) rather than an accident of two functions being called in
543/// sequence.
544///
545/// Additive by design: `shift_runs_for_delete` / `shift_runs_for_insert` are untouched,
546/// because 13 other call sites across delete/insert/paste depend on their exact
547/// behaviour. [`ReplaceFormatPolicy::InheritPreceding`] is implemented *by calling that
548/// very composition*, so the default cannot drift away from what shipped.
549///
550/// Returns `Err` rather than corrupting the block if the range is inverted or the
551/// re-splice would violate the run invariants — see [`FormatRunError`].
552pub fn shift_runs_for_replace(
553    runs: &mut Vec<FormatRun>,
554    byte_start: u32,
555    byte_end: u32,
556    replacement_bytes: u32,
557    policy: ReplaceFormatPolicy,
558) -> Result<(), FormatRunError> {
559    if byte_end < byte_start {
560        return Err(FormatRunError::ReversedRange {
561            start: byte_start,
562            end: byte_end,
563        });
564    }
565
566    // Decide what the replacement wears from the runs as they stand BEFORE the edit —
567    // afterwards the runs under the range are gone and the evidence with them.
568    //
569    // `None` here means "do not override": let the composition below decide, which is
570    // exactly `InheritPreceding`.
571    //
572    // An empty range replaces nothing, so it is an *insertion*, and the two
573    // coverage-based policies have nothing to reason about — they must defer to the
574    // insert convention rather than strip the format the typed text would have
575    // inherited. Only `PreserveNothing`, which is an explicit request for unformatted
576    // text, still applies.
577    let destroys_formatting = byte_end > byte_start;
578    let override_format: Option<Option<CharacterFormat>> = match policy {
579        ReplaceFormatPolicy::InheritPreceding => None,
580        ReplaceFormatPolicy::PreserveNothing => Some(None),
581        ReplaceFormatPolicy::PreserveIfFullyCovered => {
582            covering_format(runs, byte_start, byte_end).map(Some)
583        }
584        ReplaceFormatPolicy::KeepDominantRun if destroys_formatting => {
585            Some(dominant_format(runs, byte_start, byte_end))
586        }
587        ReplaceFormatPolicy::KeepDominantRun => None,
588    };
589
590    // The historical composition. This IS `InheritPreceding`, byte for byte.
591    shift_runs_for_delete(runs, byte_start, byte_end);
592    shift_runs_for_insert(runs, byte_start, replacement_bytes);
593
594    // Every other policy is a targeted re-splice of exactly the replacement's span.
595    if let Some(format) = override_format
596        && replacement_bytes > 0
597    {
598        let span = byte_start..byte_start + replacement_bytes;
599        let replacement = match format {
600            Some(format) => vec![FormatRun {
601                byte_start: span.start,
602                byte_end: span.end,
603                format,
604            }],
605            None => Vec::new(),
606        };
607        try_splice_range(runs, span, replacement)?;
608    }
609    Ok(())
610}
611
612/// The format of the single run covering **all** of `[start..end)`, if one does.
613///
614/// An empty range is covered by nothing: a pure insertion has no formatting of its own
615/// to preserve, so every policy falls back to inheritance there rather than silently
616/// formatting typed text from a run it merely sits next to.
617fn covering_format(runs: &[FormatRun], start: u32, end: u32) -> Option<CharacterFormat> {
618    if end <= start {
619        return None;
620    }
621    runs.iter()
622        .find(|r| r.byte_start <= start && r.byte_end >= end)
623        .map(|r| r.format.clone())
624}
625
626/// The format covering the most bytes of `[start..end)`, or `None` if unformatted text
627/// covers more than any single run.
628///
629/// Gaps count as a candidate, so renaming inside a mostly-plain range stays plain. Ties
630/// go to the formatted run: a tie means the formatting covered at least as much of the
631/// range as its absence did, and dropping it is the destructive outcome.
632fn dominant_format(runs: &[FormatRun], start: u32, end: u32) -> Option<CharacterFormat> {
633    if end <= start {
634        return None;
635    }
636    let span = u64::from(end - start);
637    let mut covered = 0u64;
638    let mut best: Option<(u64, &FormatRun)> = None;
639
640    for r in runs {
641        let lo = r.byte_start.max(start);
642        let hi = r.byte_end.min(end);
643        if hi <= lo {
644            continue;
645        }
646        let overlap = u64::from(hi - lo);
647        covered += overlap;
648        // `>` keeps the EARLIEST run on a tie between two runs, so the result does not
649        // depend on iteration order beyond the list's own sortedness.
650        if best.is_none_or(|(best_overlap, _)| overlap > best_overlap) {
651            best = Some((overlap, r));
652        }
653    }
654
655    let plain = span - covered;
656    match best {
657        Some((overlap, run)) if overlap >= plain => Some(run.format.clone()),
658        _ => None,
659    }
660}
661
662/// Apply an "insert" shift to a block's image anchors. Anchors at or
663/// past the offset move forward by `inserted_bytes`.
664pub fn shift_images_for_insert(images: &mut [ImageAnchor], byte_offset: u32, inserted_bytes: u32) {
665    if inserted_bytes == 0 {
666        return;
667    }
668    for img in images.iter_mut() {
669        if img.byte_offset >= byte_offset {
670            img.byte_offset += inserted_bytes;
671        }
672    }
673}
674
675/// Apply a "delete" mutation to a block's image anchors. Anchors whose
676/// `byte_offset` falls inside `[byte_start..byte_end)` are removed;
677/// anchors at or past `byte_end` shift back by the deleted length.
678/// Returns the number of anchors removed.
679pub fn shift_images_for_delete(
680    images: &mut Vec<ImageAnchor>,
681    byte_start: u32,
682    byte_end: u32,
683) -> usize {
684    if byte_end <= byte_start {
685        return 0;
686    }
687    let before = images.len();
688    images.retain(|i| !(i.byte_offset >= byte_start && i.byte_offset < byte_end));
689    let removed = before - images.len();
690    let delta = (byte_end - byte_start) as i32;
691    shift_images_after(images, byte_end, -delta);
692    removed
693}
694
695/// Translate a logical character offset (counting text characters AND
696/// image positions interleaved by their `byte_offset`) into a UTF-8
697/// byte offset within `plain_text`. Used by writer use cases to map a
698/// document-space char position to the byte position where text edits
699/// should land in `block.plain_text`.
700///
701/// Images contribute 1 logical character but 0 bytes in `plain_text`.
702/// Images at the same byte_offset are visited in their stored order.
703pub fn logical_offset_to_byte(plain_text: &str, images: &[ImageAnchor], char_offset: i64) -> u32 {
704    if char_offset <= 0 {
705        return 0;
706    }
707    let mut logical: i64 = 0;
708    let mut images_consumed = 0usize;
709    for (b, _) in plain_text.char_indices() {
710        while images_consumed < images.len() && images[images_consumed].byte_offset <= b as u32 {
711            if logical == char_offset {
712                return b as u32;
713            }
714            logical += 1;
715            images_consumed += 1;
716        }
717        if logical == char_offset {
718            return b as u32;
719        }
720        logical += 1;
721    }
722    let plain_len = plain_text.len() as u32;
723    while images_consumed < images.len() {
724        if logical == char_offset {
725            return plain_len;
726        }
727        logical += 1;
728        images_consumed += 1;
729    }
730    plain_len
731}
732
733/// Split a block's format runs at `byte_offset`. The returned right-hand
734/// vector has its run offsets re-based so they start at byte 0 of the
735/// new (right) block. Straddling runs are split with their `format`
736/// cloned to both halves.
737pub fn split_runs_at(runs: &[FormatRun], byte_offset: u32) -> (Vec<FormatRun>, Vec<FormatRun>) {
738    let mut left = Vec::new();
739    let mut right = Vec::new();
740    for run in runs {
741        if run.byte_end <= byte_offset {
742            left.push(run.clone());
743        } else if run.byte_start >= byte_offset {
744            right.push(FormatRun {
745                byte_start: run.byte_start - byte_offset,
746                byte_end: run.byte_end - byte_offset,
747                format: run.format.clone(),
748            });
749        } else {
750            left.push(FormatRun {
751                byte_start: run.byte_start,
752                byte_end: byte_offset,
753                format: run.format.clone(),
754            });
755            right.push(FormatRun {
756                byte_start: 0,
757                byte_end: run.byte_end - byte_offset,
758                format: run.format.clone(),
759            });
760        }
761    }
762    (left, right)
763}
764
765/// Split block image anchors at `byte_offset`. Anchors at exactly
766/// `byte_offset` go to the right half (rebased to offset 0).
767pub fn split_images_at(
768    images: &[ImageAnchor],
769    byte_offset: u32,
770) -> (Vec<ImageAnchor>, Vec<ImageAnchor>) {
771    let mut left = Vec::new();
772    let mut right = Vec::new();
773    for img in images {
774        if img.byte_offset < byte_offset {
775            left.push(img.clone());
776        } else {
777            let mut new = img.clone();
778            new.byte_offset -= byte_offset;
779            right.push(new);
780        }
781    }
782    (left, right)
783}
784
785// ─────────────────────────────────────────────────────────────────────
786// View synthesis: build a Vec<InlineSegment> from format_runs + images.
787// ─────────────────────────────────────────────────────────────────────
788
789/// Copy the `fmt_*` fields of an `InlineSegment` into a `CharacterFormat`.
790pub fn character_format_from_segment(seg: &InlineSegment) -> CharacterFormat {
791    CharacterFormat {
792        font_family: seg.fmt_font_family.clone(),
793        font_point_size: seg.fmt_font_point_size,
794        font_weight: seg.fmt_font_weight,
795        font_bold: seg.fmt_font_bold,
796        font_italic: seg.fmt_font_italic,
797        font_underline: seg.fmt_font_underline,
798        font_overline: seg.fmt_font_overline,
799        font_strikeout: seg.fmt_font_strikeout,
800        letter_spacing: seg.fmt_letter_spacing,
801        word_spacing: seg.fmt_word_spacing,
802        anchor_href: seg.fmt_anchor_href.clone(),
803        anchor_names: seg.fmt_anchor_names.clone(),
804        is_anchor: seg.fmt_is_anchor,
805        tooltip: seg.fmt_tooltip.clone(),
806        underline_style: seg.fmt_underline_style.clone(),
807        vertical_alignment: seg.fmt_vertical_alignment.clone(),
808    }
809}
810
811/// Apply a `CharacterFormat` onto an `InlineSegment`'s fmt_* fields.
812pub fn apply_character_format_to_segment(seg: &mut InlineSegment, fmt: &CharacterFormat) {
813    seg.fmt_font_family = fmt.font_family.clone();
814    seg.fmt_font_point_size = fmt.font_point_size;
815    seg.fmt_font_weight = fmt.font_weight;
816    seg.fmt_font_bold = fmt.font_bold;
817    seg.fmt_font_italic = fmt.font_italic;
818    seg.fmt_font_underline = fmt.font_underline;
819    seg.fmt_font_overline = fmt.font_overline;
820    seg.fmt_font_strikeout = fmt.font_strikeout;
821    seg.fmt_letter_spacing = fmt.letter_spacing;
822    seg.fmt_word_spacing = fmt.word_spacing;
823    seg.fmt_anchor_href = fmt.anchor_href.clone();
824    seg.fmt_anchor_names = fmt.anchor_names.clone();
825    seg.fmt_is_anchor = fmt.is_anchor;
826    seg.fmt_tooltip = fmt.tooltip.clone();
827    seg.fmt_underline_style = fmt.underline_style.clone();
828    seg.fmt_vertical_alignment = fmt.vertical_alignment.clone();
829}
830
831/// Synthesize a `Vec<InlineSegment>` view of a block from its
832/// `plain_text`, `format_runs`, and `block_images`. Returns segments
833/// in document order: a Text segment per format run (with a fallback
834/// default-format segment for any uncovered bytes), and an Image
835/// segment per anchor at its byte offset.
836///
837/// The canonical reader-side accessor for per-segment data — there is
838/// no persistent inline-element table; this view is computed fresh
839/// each call.
840pub fn inline_segments_view(
841    plain_text: &str,
842    runs: &[FormatRun],
843    images: &[ImageAnchor],
844) -> Vec<InlineSegment> {
845    let mut out: Vec<InlineSegment> = Vec::new();
846    let bytes = plain_text.as_bytes();
847
848    let mut img_iter = images.iter().peekable();
849    let mut cursor: u32 = 0;
850
851    let emit_text =
852        |out: &mut Vec<InlineSegment>, bytes: &[u8], start: u32, end: u32, fmt: CharacterFormat| {
853            if start >= end {
854                return;
855            }
856            let slice = &bytes[start as usize..end as usize];
857            let s = std::str::from_utf8(slice)
858                .expect("block plain_text must be valid UTF-8")
859                .to_string();
860            let mut seg = InlineSegment {
861                content: InlineContent::Text(s),
862                ..Default::default()
863            };
864            apply_character_format_to_segment(&mut seg, &fmt);
865            out.push(seg);
866        };
867
868    let emit_image = |out: &mut Vec<InlineSegment>, anchor: &ImageAnchor| {
869        let mut seg = InlineSegment {
870            content: InlineContent::Image {
871                name: anchor.name.clone(),
872                width: anchor.width,
873                height: anchor.height,
874                quality: anchor.quality,
875            },
876            ..Default::default()
877        };
878        apply_character_format_to_segment(&mut seg, &anchor.format);
879        out.push(seg);
880    };
881
882    for run in runs {
883        while let Some(img) = img_iter.peek() {
884            if img.byte_offset < run.byte_start {
885                emit_text(
886                    &mut out,
887                    bytes,
888                    cursor,
889                    img.byte_offset,
890                    CharacterFormat::default(),
891                );
892                emit_image(&mut out, img);
893                cursor = img.byte_offset;
894                img_iter.next();
895            } else {
896                break;
897            }
898        }
899
900        if cursor < run.byte_start {
901            emit_text(
902                &mut out,
903                bytes,
904                cursor,
905                run.byte_start,
906                CharacterFormat::default(),
907            );
908        }
909
910        emit_text(
911            &mut out,
912            bytes,
913            run.byte_start,
914            run.byte_end,
915            run.format.clone(),
916        );
917        cursor = run.byte_end;
918    }
919
920    for img in img_iter {
921        if img.byte_offset > cursor {
922            emit_text(
923                &mut out,
924                bytes,
925                cursor,
926                img.byte_offset,
927                CharacterFormat::default(),
928            );
929            cursor = img.byte_offset;
930        }
931        emit_image(&mut out, img);
932    }
933
934    if (cursor as usize) < bytes.len() {
935        emit_text(
936            &mut out,
937            bytes,
938            cursor,
939            bytes.len() as u32,
940            CharacterFormat::default(),
941        );
942    }
943
944    out
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn run(s: u32, e: u32, bold: bool) -> FormatRun {
952        FormatRun {
953            byte_start: s,
954            byte_end: e,
955            format: CharacterFormat {
956                font_bold: Some(bold),
957                ..Default::default()
958            },
959        }
960    }
961
962    #[test]
963    fn empty_runs_are_well_formed() {
964        debug_assert_well_formed(&[], 0);
965        debug_assert_well_formed(&[], 100);
966    }
967
968    #[test]
969    fn coalesce_merges_adjacent_equal_runs() {
970        let mut rs = vec![run(0, 5, true), run(5, 10, true), run(10, 15, false)];
971        coalesce_in_place(&mut rs);
972        assert_eq!(rs.len(), 2);
973        assert_eq!(rs[0].byte_end, 10);
974    }
975
976    #[test]
977    fn coalesce_leaves_disjoint_runs_alone() {
978        let mut rs = vec![run(0, 5, true), run(7, 10, true)];
979        coalesce_in_place(&mut rs);
980        assert_eq!(rs.len(), 2);
981    }
982
983    #[test]
984    fn splice_range_clips_straddling_runs() {
985        let mut rs = vec![run(0, 20, true)];
986        splice_range(&mut rs, 5..15, vec![run(5, 15, false)]);
987        assert_eq!(rs.len(), 3);
988        assert_eq!(rs[0].byte_end, 5);
989        assert_eq!(rs[1].format.font_bold, Some(false));
990        assert_eq!(rs[2].byte_start, 15);
991    }
992
993    #[test]
994    fn splice_range_empty_replacement_removes_inner_runs() {
995        let mut rs = vec![run(0, 5, true), run(5, 10, false), run(10, 15, true)];
996        splice_range(&mut rs, 5..10, vec![]);
997        // 0..5 bold, then 10..15 bold — after coalesce these are NOT adjacent
998        // (there's a gap from 5..10 in the run table, meaning "no format").
999        assert_eq!(rs.len(), 2);
1000        assert_eq!(rs[0].byte_end, 5);
1001        assert_eq!(rs[1].byte_start, 10);
1002    }
1003
1004    #[test]
1005    fn shift_after_moves_downstream() {
1006        let mut rs = vec![run(0, 5, true), run(10, 15, false)];
1007        shift_after(&mut rs, 5, 3);
1008        assert_eq!(rs[0].byte_start, 0); // unchanged
1009        assert_eq!(rs[1].byte_start, 13);
1010        assert_eq!(rs[1].byte_end, 18);
1011    }
1012}
1013
1014/// The adversarial corpus for [`shift_runs_for_replace`].
1015///
1016/// Replace is the one edit that rewrites a writer's prose *in bulk*, so its formatting
1017/// behaviour has to be pinned rather than inherited by accident. Before this module
1018/// there were 11 replace tests in the repo and **not one** of them mentioned
1019/// `FormatRun`: the behaviour was emergent, undecided, and untested.
1020#[cfg(test)]
1021mod replace_policy_tests {
1022    use super::*;
1023
1024    fn fmt(tag: &str) -> CharacterFormat {
1025        CharacterFormat {
1026            font_bold: Some(tag == "B"),
1027            font_italic: Some(tag == "I"),
1028            ..Default::default()
1029        }
1030    }
1031    fn r(start: u32, end: u32, tag: &str) -> FormatRun {
1032        FormatRun {
1033            byte_start: start,
1034            byte_end: end,
1035            format: fmt(tag),
1036        }
1037    }
1038    /// Compact "0..5=B 8..12=I" rendering, so a failure shows the whole run list.
1039    fn show(runs: &[FormatRun]) -> String {
1040        if runs.is_empty() {
1041            return "[]".to_string();
1042        }
1043        runs.iter()
1044            .map(|x| {
1045                let tag = if x.format.font_bold == Some(true) {
1046                    "B"
1047                } else if x.format.font_italic == Some(true) {
1048                    "I"
1049                } else {
1050                    "p"
1051                };
1052                format!("{}..{}={tag}", x.byte_start, x.byte_end)
1053            })
1054            .collect::<Vec<_>>()
1055            .join(" ")
1056    }
1057    fn replace(
1058        runs: &[FormatRun],
1059        start: u32,
1060        end: u32,
1061        n: u32,
1062        policy: ReplaceFormatPolicy,
1063    ) -> Vec<FormatRun> {
1064        let mut runs = runs.to_vec();
1065        shift_runs_for_replace(&mut runs, start, end, n, policy).expect("valid replace");
1066        runs
1067    }
1068
1069    /// **The spec-conformance test.** `InheritPreceding` must be byte-for-byte what
1070    /// today's `shift_runs_for_delete` + `shift_runs_for_insert` produces — anything
1071    /// else silently rewrites the formatting of every replace that ever shipped.
1072    ///
1073    /// Differential, not hand-transcribed: it runs the historical primitives directly
1074    /// and compares, so it stays honest even if their behaviour is ever changed.
1075    #[test]
1076    fn inherit_preceding_matches_the_historical_delete_then_insert() {
1077        let corpus: Vec<(&str, Vec<FormatRun>, u32, u32, u32)> = vec![
1078            ("run ends exactly at start", vec![r(0, 5, "B")], 5, 10, 3),
1079            ("run begins exactly at start", vec![r(5, 8, "B")], 5, 10, 3),
1080            (
1081                "run begins at start, outlives end",
1082                vec![r(5, 20, "B")],
1083                5,
1084                10,
1085                3,
1086            ),
1087            (
1088                "run straddles the whole range",
1089                vec![r(0, 20, "B")],
1090                5,
1091                10,
1092                3,
1093            ),
1094            ("no run touches the start", vec![r(12, 20, "B")], 5, 10, 3),
1095            ("bold tail inside the range", vec![r(9, 13, "B")], 5, 13, 4),
1096            ("pure delete", vec![r(0, 20, "B")], 5, 10, 0),
1097            ("pure insert", vec![r(0, 20, "B")], 5, 5, 3),
1098            (
1099                "same format either side coalesces",
1100                vec![r(0, 5, "B"), r(10, 15, "B")],
1101                5,
1102                10,
1103                3,
1104            ),
1105            (
1106                "different formats either side",
1107                vec![r(0, 5, "B"), r(10, 15, "I")],
1108                5,
1109                10,
1110                3,
1111            ),
1112            ("empty run list", vec![], 5, 10, 3),
1113            ("the only run is consumed", vec![r(5, 10, "B")], 5, 10, 3),
1114            (
1115                "replacement longer than the range",
1116                vec![r(0, 5, "B")],
1117                5,
1118                10,
1119                20,
1120            ),
1121            (
1122                "three runs straddled",
1123                vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")],
1124                2,
1125                7,
1126                4,
1127            ),
1128            (
1129                "gap between two same-format runs is deleted",
1130                vec![r(0, 5, "B"), r(8, 13, "B")],
1131                5,
1132                8,
1133                0,
1134            ),
1135        ];
1136
1137        for (name, runs, start, end, n) in corpus {
1138            // The historical composition, run for real.
1139            let mut expected = runs.clone();
1140            shift_runs_for_delete(&mut expected, start, end);
1141            shift_runs_for_insert(&mut expected, start, n);
1142
1143            let got = replace(&runs, start, end, n, ReplaceFormatPolicy::InheritPreceding);
1144
1145            assert_eq!(
1146                show(&got),
1147                show(&expected),
1148                "InheritPreceding diverged from delete+insert for {name:?} \
1149                 (replace {start}..{end}, n={n})\n  before:   {}\n  historical: {}\n  got:        {}",
1150                show(&runs),
1151                show(&expected),
1152                show(&got),
1153            );
1154        }
1155    }
1156
1157    /// The motivating data loss: renaming a character whose name reads `Auré**lien**`.
1158    /// The default drops the bold — that is what shipped, and it is now visible and
1159    /// chosen rather than emergent. Every other policy is a way to not lose it.
1160    #[test]
1161    fn the_four_policies_diverge_on_a_partly_bold_name() {
1162        // "Auré" plain (bytes 0..5, é is two bytes), "lien" bold (5..9).
1163        let runs = vec![r(5, 9, "B")];
1164        let (start, end, n) = (0, 9, 9); // rename the whole name, same length
1165
1166        use ReplaceFormatPolicy::*;
1167        assert_eq!(
1168            show(&replace(&runs, start, end, n, InheritPreceding)),
1169            "[]",
1170            "the historical default destroys the bold — pinned, not endorsed"
1171        );
1172        assert_eq!(
1173            show(&replace(&runs, start, end, n, PreserveNothing)),
1174            "[]",
1175            "explicitly unformatted"
1176        );
1177        assert_eq!(
1178            show(&replace(&runs, start, end, n, PreserveIfFullyCovered)),
1179            "[]",
1180            "no SINGLE run covers 0..9 — it must fall back to inheritance, not guess"
1181        );
1182        // Bold covers 4 of the 9 bytes, plain covers 5 → plain dominates.
1183        assert_eq!(
1184            show(&replace(&runs, start, end, n, KeepDominantRun)),
1185            "[]",
1186            "plain covers more of the name than the bold does"
1187        );
1188
1189        // …but when the bold covers MOST of the name, KeepDominantRun keeps it.
1190        let mostly_bold = vec![r(1, 9, "B")];
1191        assert_eq!(
1192            show(&replace(&mostly_bold, 0, 9, 9, KeepDominantRun)),
1193            "0..9=B",
1194            "bold covers 8 of 9 bytes — the rename must keep it"
1195        );
1196    }
1197
1198    /// "Fully covered" means ONE run covers the range — not "the runs jointly span it".
1199    /// A gapless Italic+Bold union spanning the range exactly must NOT be treated as
1200    /// covered, or the replacement silently inherits whichever run was looked at first.
1201    #[test]
1202    fn fully_covered_means_a_single_run_not_a_gapless_union() {
1203        let two = vec![r(0, 3, "I"), r(3, 10, "B")];
1204        assert_eq!(
1205            show(&replace(
1206                &two,
1207                0,
1208                10,
1209                4,
1210                ReplaceFormatPolicy::PreserveIfFullyCovered
1211            )),
1212            "[]",
1213            "two different-format runs jointly spanning the range are not 'covered'; \
1214             with no run preceding the start, the fallback is unformatted"
1215        );
1216
1217        // One run that really does cover it, and begins exactly at the start — the case
1218        // InheritPreceding cannot see (a run at `start` never inherits).
1219        let one = vec![r(5, 20, "B")];
1220        assert_eq!(
1221            show(&replace(
1222                &one,
1223                5,
1224                10,
1225                3,
1226                ReplaceFormatPolicy::PreserveIfFullyCovered
1227            )),
1228            "5..18=B",
1229            "a single covering run keeps its format across the rename"
1230        );
1231        assert_eq!(
1232            show(&replace(
1233                &one,
1234                5,
1235                10,
1236                3,
1237                ReplaceFormatPolicy::InheritPreceding
1238            )),
1239            "8..18=B",
1240            "…which the default would have lost: the replacement lands unformatted"
1241        );
1242    }
1243
1244    /// A run that merely *touches* the range must not leak its format to the whole
1245    /// replacement.
1246    #[test]
1247    fn a_partially_overlapping_run_does_not_count_as_covering() {
1248        let runs = vec![r(0, 8, "B")]; // covers only 5..8 of the range 5..12
1249        assert_eq!(
1250            show(&replace(
1251                &runs,
1252                5,
1253                12,
1254                4,
1255                ReplaceFormatPolicy::PreserveIfFullyCovered
1256            )),
1257            "0..9=B",
1258            "not covered → falls back to inheritance, which extends the preceding bold; \
1259             it must NOT format the whole replacement as though bold had covered it"
1260        );
1261    }
1262
1263    /// Ties between two runs resolve to the EARLIEST, deterministically.
1264    ///
1265    /// `Iterator::max_by_key` returns the LAST maximum, so the obvious one-liner would
1266    /// have silently picked the other run here.
1267    #[test]
1268    fn a_dominance_tie_between_two_runs_goes_to_the_earlier() {
1269        let runs = vec![r(0, 3, "B"), r(3, 6, "I")]; // 3 bytes each
1270        assert_eq!(
1271            show(&replace(
1272                &runs,
1273                0,
1274                6,
1275                4,
1276                ReplaceFormatPolicy::KeepDominantRun
1277            )),
1278            "0..4=B",
1279            "a true tie must resolve to the earlier run, not to whichever the iterator \
1280             happened to visit last"
1281        );
1282    }
1283
1284    /// A tie between a run and the unformatted gap goes to the run: losing formatting is
1285    /// the destructive outcome, so it needs a strict majority of *plain* to win.
1286    #[test]
1287    fn a_dominance_tie_against_plain_text_keeps_the_formatting() {
1288        let runs = vec![r(4, 8, "B")]; // 4 bold bytes, 4 plain bytes in 0..8
1289        assert_eq!(
1290            show(&replace(
1291                &runs,
1292                0,
1293                8,
1294                5,
1295                ReplaceFormatPolicy::KeepDominantRun
1296            )),
1297            "0..5=B",
1298            "an even split must keep the formatting rather than silently drop it"
1299        );
1300    }
1301
1302    /// An empty range replaces nothing, so it is an insertion — and every policy that
1303    /// reasons about "what was covered" must defer to the insert convention instead of
1304    /// stripping the format the typed text would have inherited.
1305    #[test]
1306    fn an_empty_range_is_an_insert_and_no_coverage_policy_overrides_it() {
1307        let runs = vec![r(0, 5, "B"), r(5, 10, "I")];
1308        use ReplaceFormatPolicy::*;
1309        for policy in [InheritPreceding, PreserveIfFullyCovered, KeepDominantRun] {
1310            assert_eq!(
1311                show(&replace(&runs, 5, 5, 2, policy)),
1312                "0..7=B 7..12=I",
1313                "{policy:?}: typing at a boundary must inherit the run to the LEFT (Qt \
1314                 convention) — an empty range destroyed no formatting, so there is \
1315                 nothing for a coverage policy to override"
1316            );
1317        }
1318        // The one policy that is an explicit request, not an inference, still applies.
1319        assert_eq!(
1320            show(&replace(&runs, 5, 5, 2, PreserveNothing)),
1321            "0..5=B 7..12=I",
1322            "PreserveNothing asks for unformatted text, and means it even on an insert"
1323        );
1324    }
1325
1326    /// Nothing to do must mean nothing done — no fabricated runs, no lost ones.
1327    #[test]
1328    fn a_zero_width_zero_length_replace_is_the_identity() {
1329        let runs = vec![r(0, 5, "B"), r(7, 12, "I")];
1330        for policy in [
1331            ReplaceFormatPolicy::InheritPreceding,
1332            ReplaceFormatPolicy::PreserveIfFullyCovered,
1333            ReplaceFormatPolicy::KeepDominantRun,
1334            ReplaceFormatPolicy::PreserveNothing,
1335        ] {
1336            assert_eq!(
1337                show(&replace(&runs, 6, 6, 0, policy)),
1338                "0..5=B 7..12=I",
1339                "{policy:?} changed a no-op edit"
1340            );
1341        }
1342    }
1343
1344    /// `PreserveNothing` must leave the region genuinely *unformatted* — not covered by
1345    /// a fabricated run carrying `CharacterFormat::default()`, which is a different
1346    /// thing and would defeat coalescing forever after.
1347    #[test]
1348    fn preserve_nothing_fabricates_no_default_run() {
1349        let runs = vec![r(0, 5, "B")];
1350        let got = replace(&runs, 7, 9, 2, ReplaceFormatPolicy::PreserveNothing);
1351        assert_eq!(show(&got), "0..5=B", "no run may be invented for the gap");
1352        assert!(
1353            got.iter().all(|x| x.byte_start < 7 || x.byte_end > 9),
1354            "the replaced span must carry no run at all"
1355        );
1356    }
1357
1358    /// Offsets are BYTES, not chars. A 4-byte emoji replaced by 2 ASCII bytes must
1359    /// shift downstream runs by -2, not by -1 (chars) or 0.
1360    #[test]
1361    fn offsets_are_bytes_not_characters() {
1362        // "🎉" (4 bytes, bold) + " " + "abcd" (italic).
1363        let runs = vec![r(0, 4, "B"), r(5, 9, "I")];
1364        let got = replace(&runs, 0, 4, 2, ReplaceFormatPolicy::KeepDominantRun);
1365        assert_eq!(
1366            show(&got),
1367            "0..2=B 3..7=I",
1368            "the trailing italic must shift back by the BYTE delta (4 -> 2 = -2)"
1369        );
1370    }
1371
1372    /// Every policy must leave the run list well-formed — sorted, non-overlapping,
1373    /// coalesced, inside the block. This is the invariant a release build no longer
1374    /// merely asserts.
1375    #[test]
1376    fn every_policy_leaves_the_runs_well_formed() {
1377        let setups: Vec<(Vec<FormatRun>, u32, u32, u32, usize)> = vec![
1378            (vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")], 2, 7, 4, 8),
1379            (vec![r(0, 5, "B"), r(8, 13, "B")], 5, 8, 0, 10),
1380            (vec![r(0, 5, "B"), r(7, 12, "B")], 8, 10, 2, 12),
1381            (vec![r(3, 7, "B")], 3, 7, 0, 6),
1382            (vec![], 2, 6, 3, 9),
1383        ];
1384        for (runs, start, end, n, text_len) in setups {
1385            for policy in [
1386                ReplaceFormatPolicy::InheritPreceding,
1387                ReplaceFormatPolicy::PreserveIfFullyCovered,
1388                ReplaceFormatPolicy::KeepDominantRun,
1389                ReplaceFormatPolicy::PreserveNothing,
1390            ] {
1391                let got = replace(&runs, start, end, n, policy);
1392                check_well_formed(&got, text_len).unwrap_or_else(|e| {
1393                    panic!(
1394                        "{policy:?} produced malformed runs from {} (replace {start}..{end}, \
1395                         n={n}): {} — {e}",
1396                        show(&runs),
1397                        show(&got)
1398                    )
1399                });
1400            }
1401        }
1402    }
1403
1404    /// Two same-format runs separated by an untouched gap must stay separate — coalescing
1405    /// is for *adjacent* runs, and over-eager merging would swallow the plain text between
1406    /// them.
1407    #[test]
1408    fn an_untouched_gap_between_equal_runs_survives() {
1409        let runs = vec![r(0, 5, "B"), r(7, 12, "B")];
1410        assert_eq!(
1411            show(&replace(
1412                &runs,
1413                8,
1414                10,
1415                2,
1416                ReplaceFormatPolicy::InheritPreceding
1417            )),
1418            "0..5=B 7..12=B",
1419            "the plain gap at 5..7 must not be swallowed"
1420        );
1421    }
1422
1423    /// An inverted range is refused, not asserted away.
1424    #[test]
1425    fn an_inverted_range_is_an_error_not_a_panic() {
1426        let mut runs = vec![r(0, 5, "B")];
1427        let err =
1428            shift_runs_for_replace(&mut runs, 10, 5, 3, ReplaceFormatPolicy::InheritPreceding)
1429                .expect_err("an inverted range must be rejected");
1430        assert!(matches!(
1431            err,
1432            FormatRunError::ReversedRange { start: 10, end: 5 }
1433        ));
1434        assert_eq!(show(&runs), "0..5=B", "a refused edit must change nothing");
1435    }
1436}
1437
1438/// The runtime guards that replaced the release-mode-invisible `debug_assert!`s.
1439#[cfg(test)]
1440mod invariant_check_tests {
1441    use super::*;
1442
1443    fn run(start: u32, end: u32, bold: bool) -> FormatRun {
1444        FormatRun {
1445            byte_start: start,
1446            byte_end: end,
1447            format: CharacterFormat {
1448                font_bold: Some(bold),
1449                ..Default::default()
1450            },
1451        }
1452    }
1453
1454    /// The whole point: in a **release** build `debug_assert!` is gone, so a violating
1455    /// splice used to sail straight through and build a malformed run list. Now it is
1456    /// reported, and the caller's runs are left exactly as they were.
1457    #[test]
1458    fn a_replacement_outside_the_range_is_rejected_without_mutating() {
1459        let mut runs = vec![run(0, 20, true)];
1460        let before = runs.clone();
1461
1462        let err = try_splice_range(&mut runs, 5..10, vec![run(5, 15, false)])
1463            .expect_err("a replacement run reaching past range.end must be rejected");
1464
1465        assert!(matches!(
1466            err,
1467            FormatRunError::ReplacementOutsideRange {
1468                run_end: 15,
1469                range_end: 10,
1470                ..
1471            }
1472        ));
1473        assert_eq!(
1474            runs, before,
1475            "a rejected splice must not half-apply — validation happens before mutation"
1476        );
1477    }
1478
1479    #[test]
1480    // The inverted range is the whole point of the test — it is what a caller must not
1481    // be able to sneak past a release build.
1482    #[allow(clippy::reversed_empty_ranges)]
1483    fn a_reversed_range_is_rejected() {
1484        let mut runs = vec![run(0, 20, true)];
1485        assert!(matches!(
1486            try_splice_range(&mut runs, 10..5, vec![]),
1487            Err(FormatRunError::ReversedRange { start: 10, end: 5 })
1488        ));
1489    }
1490
1491    #[test]
1492    fn a_legal_splice_still_works_through_the_checked_path() {
1493        let mut runs = vec![run(0, 20, true)];
1494        try_splice_range(&mut runs, 5..15, vec![run(5, 15, false)]).expect("legal");
1495        assert_eq!(runs.len(), 3);
1496        assert_eq!(runs[1].format.font_bold, Some(false));
1497    }
1498
1499    #[test]
1500    fn check_well_formed_catches_what_debug_assert_used_to() {
1501        assert!(check_well_formed(&[], 0).is_ok());
1502        assert!(check_well_formed(&[run(0, 5, true)], 5).is_ok());
1503
1504        assert!(matches!(
1505            check_well_formed(&[run(5, 5, true)], 10),
1506            Err(FormatRunError::EmptyRun { .. })
1507        ));
1508        assert!(matches!(
1509            check_well_formed(&[run(0, 8, true), run(5, 10, false)], 10),
1510            Err(FormatRunError::RunsOverlap { .. })
1511        ));
1512        assert!(matches!(
1513            check_well_formed(&[run(0, 5, true), run(5, 10, true)], 10),
1514            Err(FormatRunError::RunsNotCoalesced { .. })
1515        ));
1516        assert!(matches!(
1517            check_well_formed(&[run(0, 20, true)], 10),
1518            Err(FormatRunError::RunPastEndOfBlock { text_len: 10, .. })
1519        ));
1520    }
1521}