Skip to main content

quillmark_content/
model.rs

1//! The `Content` content model — one text sequence per field carrying line
2//! attributes, anchored marks, and embedded islands, over a single coordinate
3//! space of Unicode scalar values (Rust `char`).
4//!
5//! This is the freeze (issue #831): the mark set, the three
6//! normalization rules, and the invariants are what canonical serialization
7//! commits to. Everything an editor disagrees on (edge-expand,
8//! adjacent-merge-at-insertion) is *not* encoded — the model only ever stores
9//! the resulting range, so the stored form is identical whatever the editor
10//! did.
11
12use crate::normalize::is_bidi_char;
13use serde_json::Value as JsonValue;
14
15/// A position in a [`Content`], counted in Unicode scalar values (USV) — never
16/// bytes, never UTF-16 units. One astral char is 1 USV / 4 UTF-8 bytes / 2
17/// UTF-16 units. Conversions to/from the JS (UTF-16) and Rust (UTF-8)
18/// boundaries live in [`crate::usv`].
19pub type Usv = usize;
20
21/// U+FFFC OBJECT REPLACEMENT CHARACTER — the single-USV slot an island occupies
22/// in the content. One slot per island; every slot has a backing island. A stray
23/// slot (or a slot with no island) is an invariant violation.
24pub const ISLAND_SLOT: char = '\u{FFFC}';
25
26/// One content field as a content: the text plus the structure that rides on it.
27///
28/// Invariants (established once by import normalization, checked by
29/// [`Content::validate`]): the text holds no `\r` and no bidi controls; the
30/// count of [`ISLAND_SLOT`] equals `islands.len()`; `lines.len()` equals the
31/// number of `\n`-separated segments; marks are normalized (sorted, unioned).
32#[derive(Debug, Clone, PartialEq)]
33pub struct Content {
34    /// The content. `\n` is a line boundary; [`ISLAND_SLOT`] is an island slot.
35    pub text: String,
36    /// One entry per `\n`-separated segment of `text`, in order. The line tree
37    /// is *derived* from this flat list plus each line's `containers` path — it
38    /// is never stored, so a split/join is a single-char edit with no identity
39    /// crisis (there are no paragraph IDs).
40    pub lines: Vec<Line>,
41    /// Marks over char ranges, kept normalized: sorted by
42    /// `(start, end, kind-ord, attrs)`, same-kind formatting marks unioned.
43    pub marks: Vec<Mark>,
44    /// One entry per [`ISLAND_SLOT`], in slot order (ascending char position).
45    pub islands: Vec<Island>,
46}
47
48/// A line's attributes: its block role plus the container path it sits in.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Line {
51    pub kind: LineKind,
52    /// Ancestor containers, outermost first. A multi-paragraph list item is two
53    /// `Para` lines sharing one `[ListItem]` path; a paragraph in a quote in a
54    /// list item is `[ListItem, Quote]`.
55    pub containers: Vec<Container>,
56    /// Whether this line continues the previous line's *block* across a hard
57    /// line break (no paragraph break between), rather than starting a new
58    /// block. `false` = a new block (paragraph spacing on either side); `true` =
59    /// a within-block line break (markdown hard break; consecutive lines of one
60    /// code fence). The first line is always `false`. This is what keeps a hard
61    /// break (backend `#linebreak()`) distinct from a paragraph boundary through
62    /// the freeze, and what groups a code fence's lines without an
63    /// adjacency heuristic.
64    pub continues: bool,
65}
66
67/// The block role of a line. The tree between lines is inferred: two adjacent
68/// lines with equal `kind`+`containers` are two blocks of that role (e.g. two
69/// paragraphs), never one.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum LineKind {
72    Para,
73    /// ATX/Setext heading, level 1..=6.
74    Heading {
75        level: u8,
76    },
77    /// A line of a code block. `lang` is the (sanitized) info string, shared by
78    /// every line of the same block.
79    Code {
80        lang: Option<String>,
81    },
82    /// A block-level island: the line's sole content is one [`ISLAND_SLOT`].
83    Island,
84    /// A thematic break (`---`/`***`/`___`). The line carries no text — the
85    /// break is the line itself, parallel to how an island's content is its
86    /// one slot char.
87    Rule,
88}
89
90/// A container a line nests inside. The ancestor path is a `Vec<Container>`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum Container {
93    /// A list item. `ordered` distinguishes `1.` from `-`; `start` is the list's
94    /// first number (1 by default); `ordinal` is this item's 0-based index in
95    /// its list. Two *adjacent* lines belong to the same item iff their whole
96    /// container path (ordinals included) is equal — so a multi-paragraph item
97    /// is two lines sharing one `ListItem`, while the next item differs by
98    /// `ordinal`. (Identity is path **plus contiguity**: two sibling inner lists
99    /// under one outer item can produce equal first-item paths, distinguished
100    /// only by the non-adjacency of their runs.) Positional and deterministic —
101    /// no minted ids.
102    ListItem {
103        ordered: bool,
104        start: u64,
105        ordinal: u64,
106    },
107    /// A block quote. Adjacent lines sharing `[Quote]` are one multi-paragraph
108    /// quote; two adjacent separate quotes are not distinguished (they merge on
109    /// round-trip — a documented canonicalization).
110    Quote,
111}
112
113/// A mark over a char range `[start, end)`. `start == end` (zero-width) is legal
114/// only for [`MarkKind::Anchor`]; normalization drops zero-width formatting.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Mark {
117    pub start: Usv,
118    pub end: Usv,
119    pub kind: MarkKind,
120}
121
122/// The mark set — **open**: an unknown kind round-trips as [`MarkKind::Unknown`],
123/// absorbed as a new *type*, never a changed semantics of a known one. Two
124/// algebra classes: formatting is a property of a range (two coincident are
125/// redundant); identity is a handle (two over the same range are two things).
126#[derive(Debug, Clone, PartialEq)]
127pub enum MarkKind {
128    // Formatting — round-trippable projection marks. `is_formatting()`.
129    Strong,
130    Emph,
131    Underline,
132    Strike,
133    Code,
134    Link {
135        url: String,
136    },
137    // Identity — a handle, not a property. Never merged, may be zero-width.
138    /// A comment thread or stable anchor, carried by id and rebased across
139    /// edits like any position. The id is caller-supplied, unique per `Content`,
140    /// opaque and invariant while the mark lives; positions rebase, the id never
141    /// does, and moved-and-rewritten text drops the mark whole
142    /// (`DOCUMENT_STORAGE.md` § Anchor-id identity). No markdown projection
143    /// (omitted on export; survives via diff-rebase).
144    Anchor {
145        id: String,
146    },
147    // Open-set escape hatch — an unknown mark type, round-tripped opaque.
148    Unknown {
149        tag: String,
150        attrs: JsonValue,
151    },
152}
153
154/// A structured object with no honest text encoding — a table, figure, or future
155/// embed — occupying one [`ISLAND_SLOT`] in the content.
156#[derive(Debug, Clone, PartialEq)]
157pub struct Island {
158    /// Deterministically minted, session-stable id — `isl-{n}` by import
159    /// position (`import::mint_island`). Part of the canonical form and thus
160    /// hash input; deterministic by contract, never ambient, so equal content
161    /// hashes equal (`DOCUMENT_STORAGE.md` § Island-id determinism). Edits keep
162    /// it stable rather than re-deriving it, so [`Content::validate`] enforces
163    /// uniqueness, not positional equality.
164    pub id: String,
165    /// Island type discriminator (`"table"`, `"image"`, …). Unknown types
166    /// round-trip opaque.
167    pub island_type: String,
168    /// Typed payload. Recursively key-sorted by normalization so it hashes
169    /// deterministically despite `serde_json`'s `preserve_order`.
170    pub props: JsonValue,
171    /// How faithfully the markdown projection can carry this island.
172    pub loss: Loss,
173}
174
175/// The markdown-projection loss class of an island.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Loss {
178    /// Markdown carries it faithfully (round-trips identically).
179    Lossless,
180    /// Markdown carries an approximation (round-trips visibly, not identically).
181    Degraded,
182    /// No markdown encoding; export emits a placeholder only.
183    Unrepresentable,
184}
185
186impl MarkKind {
187    /// Formatting marks are a property of a range and union when coincident;
188    /// identity/unknown marks are handles and never merge (Spike-A rules).
189    pub fn is_formatting(&self) -> bool {
190        matches!(
191            self,
192            MarkKind::Strong
193                | MarkKind::Emph
194                | MarkKind::Underline
195                | MarkKind::Strike
196                | MarkKind::Code
197                | MarkKind::Link { .. }
198        )
199    }
200
201    /// Total order over kinds for the canonical sort tie-break, after
202    /// `(start, end)`. Stable across releases — part of the freeze.
203    pub fn ord(&self) -> u8 {
204        match self {
205            MarkKind::Strong => 0,
206            MarkKind::Emph => 1,
207            MarkKind::Underline => 2,
208            MarkKind::Strike => 3,
209            MarkKind::Code => 4,
210            MarkKind::Link { .. } => 5,
211            MarkKind::Anchor { .. } => 6,
212            MarkKind::Unknown { .. } => 7,
213        }
214    }
215
216    /// Attribute tie-break string, appended after `ord` in the canonical sort so
217    /// two marks that differ only in attrs order deterministically. Also the
218    /// grouping key for same-kind union (two formatting marks union only when
219    /// this matches — e.g. two `link`s union only at the same url).
220    pub fn attrs_key(&self) -> String {
221        match self {
222            MarkKind::Link { url } => url.clone(),
223            MarkKind::Anchor { id } => id.clone(),
224            MarkKind::Unknown { tag, attrs } => {
225                // Attrs sorted so the key is order-insensitive.
226                format!("{}\u{0}{}", tag, canonical_json_string(attrs))
227            }
228            _ => String::new(),
229        }
230    }
231}
232
233/// A `serde_json::Value` rendered to a string with object keys recursively
234/// sorted — order-insensitive, so it is a stable comparison/grouping key.
235fn canonical_json_string(v: &JsonValue) -> String {
236    serde_json::to_string(&sorted_value(v)).unwrap_or_default()
237}
238
239/// Rebuild `v` with every object's keys sorted, recursively. Pins island
240/// `props` (and unknown-mark attrs) against `preserve_order` leaking insertion
241/// order into the canonical bytes / content hash (Spike C carry-forward). For
242/// an owned tree, prefer [`sort_keys_owned`] — it reorders in place without
243/// cloning the leaves.
244pub(crate) fn sorted_value(v: &JsonValue) -> JsonValue {
245    match v {
246        JsonValue::Array(items) => JsonValue::Array(items.iter().map(sorted_value).collect()),
247        JsonValue::Object(map) => {
248            let mut keys: Vec<&String> = map.keys().collect();
249            keys.sort();
250            let mut out = serde_json::Map::with_capacity(map.len());
251            for k in keys {
252                out.insert(k.clone(), sorted_value(&map[k]));
253            }
254            JsonValue::Object(out)
255        }
256        other => other.clone(),
257    }
258}
259
260/// Whether every object in `v` already has its keys in ascending order,
261/// recursively — the cheap allocation-free check that lets a re-normalize skip
262/// rebuilding an already-canonical `props`/`attrs` tree via [`sorted_value`].
263/// Once normalized, an untouched tree stays sorted, so a per-keystroke
264/// re-normalize pays a scan instead of a full clone.
265pub(crate) fn is_value_key_sorted(v: &JsonValue) -> bool {
266    match v {
267        JsonValue::Array(items) => items.iter().all(is_value_key_sorted),
268        JsonValue::Object(map) => {
269            map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
270                && map.values().all(is_value_key_sorted)
271        }
272        _ => true,
273    }
274}
275
276/// The owned twin of [`sorted_value`]: reorder every object's keys by **moving**
277/// each entry into a freshly key-sorted map, recursively. Same canonical result
278/// — the fixed struct keys land alphabetically and any already-sorted `props`/
279/// `attrs` re-sort to themselves — but the leaves (the `text` string, mark
280/// attrs, arrays) are moved rather than deep-cloned, so a tree built once by
281/// `to_value` is canonicalized without a second full clone. Re-sorting a new
282/// `serde_json::Map` (not sorting in place) keeps this independent of whether
283/// `serde_json`'s `preserve_order` feature is on in the crate graph.
284pub(crate) fn sort_keys_owned(v: JsonValue) -> JsonValue {
285    match v {
286        JsonValue::Array(items) => {
287            JsonValue::Array(items.into_iter().map(sort_keys_owned).collect())
288        }
289        JsonValue::Object(map) => {
290            let mut entries: Vec<(String, JsonValue)> = map.into_iter().collect();
291            entries.sort_by(|a, b| a.0.cmp(&b.0));
292            let mut out = serde_json::Map::with_capacity(entries.len());
293            for (k, child) in entries {
294                out.insert(k, sort_keys_owned(child));
295            }
296            JsonValue::Object(out)
297        }
298        other => other,
299    }
300}
301
302/// Ways a [`Content`] can violate its invariants. Returned by
303/// [`Content::validate`]; import normalization guarantees none of these.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum Invariant {
306    /// `\r` in the text (line endings must be normalized to `\n`).
307    CarriageReturn,
308    /// A bidi formatting control in the text.
309    BidiControl(char),
310    /// `island_slot_count != islands.len()`.
311    IslandSlotMismatch { slots: usize, islands: usize },
312    /// `lines.len() != newline_segment_count`.
313    LineCountMismatch { lines: usize, segments: usize },
314    /// A mark range runs past the content or is inverted (`start > end`).
315    MarkOutOfRange { start: Usv, end: Usv, len: Usv },
316    /// A zero-width formatting mark survived normalization.
317    ZeroWidthFormatting { at: Usv },
318    /// A heading level outside 1..=6.
319    BadHeadingLevel(u8),
320    /// The first line has `continues: true` (nothing precedes it to continue).
321    FirstLineContinues,
322    /// An [`MarkKind::Unknown`] reused a reserved built-in `type` name.
323    ReservedUnknownTag(String),
324    /// A formatting mark edge sits on a `\n` (normalization should have trimmed
325    /// it) — a hand-built content that skipped `normalize`.
326    MarkEdgeOnNewline { at: Usv },
327    /// A table island's `aligns` length differs from its column count (the
328    /// header width). `normalize` syncs `aligns` to the column count.
329    TableAlignsMismatch { aligns: usize, cols: usize },
330    /// A table island body row's width differs from the column count (the header
331    /// width). `normalize` pads short rows (and the header) to the widest.
332    TableRaggedRow { row: usize, width: usize, cols: usize },
333    /// A table cell's text carries a `\n` — cells are single-line (a newline
334    /// would break the exported table). `cell` is the flat header-then-rows
335    /// index; `normalize` rewrites the newline to a space.
336    TableCellNewline { cell: usize },
337    /// Two islands share an `id`. Ids are deterministic, session-stable
338    /// identities (hash input, so never ambient); import mints them by index so
339    /// they never collide, but a hand-built or round-tripped content can.
340    /// Downstream code that keys islands by id would otherwise silently pick the
341    /// wrong one. Uniqueness is the id invariant `validate` enforces — positional
342    /// equality is not, since edits keep an island's id stable across renumbers.
343    IslandIdCollision { id: String },
344    /// Two prose anchors share an `id`, or one carries the empty id. An anchor
345    /// id is a caller-supplied, opaque handle, unique per `Content` (hash input,
346    /// never ambient in the twin's sense; `DOCUMENT_STORAGE.md` § Anchor-id
347    /// identity). `RemoveAnchor { id }` retains-out *every* match, so a shared id
348    /// makes removing one destroy both; the empty id is a degenerate handle.
349    /// Scope is prose marks — cell anchors are outside the op surface.
350    AnchorIdCollision { id: String },
351    /// A table island's `header` prop is present but not a JSON array — it
352    /// cannot carry column cells. `normalize` rewrites a non-array header to an
353    /// empty array (a zero-column, content-free table).
354    TableHeaderNotArray,
355}
356
357impl Content {
358    /// An empty content: one empty `Para` line, no marks, no islands.
359    pub fn empty() -> Self {
360        Content {
361            text: String::new(),
362            lines: vec![Line {
363                kind: LineKind::Para,
364                containers: Vec::new(),
365                continues: false,
366            }],
367            marks: Vec::new(),
368            islands: Vec::new(),
369        }
370    }
371
372    /// Total length in USV.
373    pub fn len_usv(&self) -> Usv {
374        self.text.chars().count()
375    }
376
377    /// Whether this content satisfies the `richtext(inline)` constraint: exactly
378    /// one `Para` line, sitting in no container, with no islands. A single line
379    /// can never `continues` (line 0 is always `false`), so that dimension is
380    /// implied. [`Content::empty`] is inline (one empty `Para`), so a blank or
381    /// zero-filled inline field passes.
382    pub fn is_inline(&self) -> bool {
383        self.islands.is_empty()
384            && self.lines.len() == 1
385            && self.lines[0].kind == LineKind::Para
386            && self.lines[0].containers.is_empty()
387    }
388
389    /// Whether this content satisfies the `plaintext` constraint: no marks, no
390    /// islands, and every line is a plain `Para` sitting in no container. It is
391    /// the multi-line generalization of [`is_inline`](Self::is_inline) (which
392    /// additionally pins the content to one line) with the mark/island exclusion
393    /// made explicit — a plaintext value carries prose the author navigates but
394    /// no formatting. `continues` is unconstrained: a lone `\n` may be a
395    /// within-paragraph break. [`Content::empty`] is plain.
396    ///
397    /// This is the plaintext analogue of `is_inline`, enforced at coercion and
398    /// validation with the `NotPlain` error; the distinguishing property of
399    /// plaintext over `richtext { marks: [] }` is the *literal* codec
400    /// ([`crate::import::from_plaintext`]), not this predicate.
401    pub fn is_plain(&self) -> bool {
402        self.marks.is_empty()
403            && self.islands.is_empty()
404            && self
405                .lines
406                .iter()
407                .all(|l| l.kind == LineKind::Para && l.containers.is_empty())
408    }
409
410    /// Whether the content carries no renderable content: the text is empty or
411    /// whitespace-only. An island slot ([`ISLAND_SLOT`], U+FFFC) is not
412    /// whitespace, so an island-bearing content is never blank. Body-disabled
413    /// validation and round-trip emit key on it.
414    pub fn is_blank(&self) -> bool {
415        self.text.trim().is_empty()
416    }
417
418    /// Number of `\n`-separated segments — the required `lines.len()`.
419    pub fn segment_count(&self) -> usize {
420        self.text.chars().filter(|c| *c == '\n').count() + 1
421    }
422
423    /// Normalize marks in place: drop zero-width formatting, union same-kind
424    /// formatting that is adjacent or overlapping, recursively key-sort island
425    /// props and unknown-mark attrs, then sort marks canonically. Idempotent —
426    /// the fixed point the canonical serialization commits to.
427    pub fn normalize(&mut self) {
428        // Islands: canonicalize props key order. A table island's cells carry
429        // inline `{text, marks}`; repair its shape (pad the header/rows/aligns to
430        // one column count, rewrite any cell `\n` to a space) and canonicalize
431        // each cell's marks (sort, union, drop zero-width) first so equal cells
432        // serialize to equal bytes and `validate` holds — the props are
433        // otherwise opaque here.
434        for island in &mut self.islands {
435            crate::island::normalize_island_structure(island);
436            // Rebuild props only when a key is actually out of order — an
437            // untouched island (a pure text splice) stays sorted, so this skips
438            // the deep clone on the common per-keystroke path.
439            if !is_value_key_sorted(&island.props) {
440                island.props = sorted_value(&island.props);
441            }
442        }
443        for mark in &mut self.marks {
444            if let MarkKind::Unknown { attrs, .. } = &mut mark.kind {
445                if !is_value_key_sorted(attrs) {
446                    *attrs = sorted_value(attrs);
447                }
448            }
449        }
450        // A formatting mark's edges never sit on a line boundary: markdown can't
451        // bold a `\n`, so two producers that disagree only about whether the
452        // boundary is "inside" the mark must canonicalize to the same bounds.
453        // Trim leading/trailing `\n` (interior boundaries are kept — a mark may
454        // legitimately span lines). Zero-width results are dropped below.
455        // Skip the full-text char collection when nothing needs trimming.
456        if self.marks.iter().any(|m| m.kind.is_formatting()) {
457            let chars: Vec<char> = self.text.chars().collect();
458            for m in &mut self.marks {
459                if m.kind.is_formatting() {
460                    while m.start < m.end && chars.get(m.start) == Some(&'\n') {
461                        m.start += 1;
462                    }
463                    while m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
464                        m.end -= 1;
465                    }
466                }
467            }
468        }
469        self.marks = normalize_marks(std::mem::take(&mut self.marks));
470    }
471
472    /// Mark `type` names the projection reserves; an [`MarkKind::Unknown`] may
473    /// not reuse one (its serialization would parse back as the built-in,
474    /// silently dropping its attrs — non-injective). Checked by [`Content::validate`].
475    pub const RESERVED_MARK_TYPES: [&'static str; 7] = [
476        "strong",
477        "emph",
478        "underline",
479        "strike",
480        "code",
481        "link",
482        "anchor",
483    ];
484
485    /// Check every invariant. `Ok(())` on a well-formed content. Import
486    /// guarantees this; a hand-built content should be run through it in tests.
487    pub fn validate(&self) -> Result<(), Invariant> {
488        let mut slots = 0usize;
489        for c in self.text.chars() {
490            if c == '\r' {
491                return Err(Invariant::CarriageReturn);
492            }
493            if is_bidi_char(c) {
494                return Err(Invariant::BidiControl(c));
495            }
496            if c == ISLAND_SLOT {
497                slots += 1;
498            }
499        }
500        if slots != self.islands.len() {
501            return Err(Invariant::IslandSlotMismatch {
502                slots,
503                islands: self.islands.len(),
504            });
505        }
506        let segments = self.segment_count();
507        if self.lines.len() != segments {
508            return Err(Invariant::LineCountMismatch {
509                lines: self.lines.len(),
510                segments,
511            });
512        }
513        if self.lines.first().is_some_and(|l| l.continues) {
514            return Err(Invariant::FirstLineContinues);
515        }
516        let len = self.len_usv();
517        let chars: Vec<char> = self.text.chars().collect();
518        // Prose anchor ids: unique, non-empty, caller-supplied opaque handles
519        // (`DOCUMENT_STORAGE.md` § Anchor-id identity). Uniqueness — the same
520        // invariant the island loop enforces below — is what `RemoveAnchor`
521        // presumes; scope is prose marks, cell anchors excluded by construction.
522        let mut seen_anchor_ids = std::collections::HashSet::new();
523        for m in &self.marks {
524            if m.start > m.end || m.end > len {
525                return Err(Invariant::MarkOutOfRange {
526                    start: m.start,
527                    end: m.end,
528                    len,
529                });
530            }
531            if m.start == m.end && m.kind.is_formatting() {
532                return Err(Invariant::ZeroWidthFormatting { at: m.start });
533            }
534            if m.kind.is_formatting() {
535                if chars.get(m.start) == Some(&'\n') {
536                    return Err(Invariant::MarkEdgeOnNewline { at: m.start });
537                }
538                if m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
539                    return Err(Invariant::MarkEdgeOnNewline { at: m.end - 1 });
540                }
541            }
542            match &m.kind {
543                MarkKind::Unknown { tag, .. } => {
544                    if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
545                        return Err(Invariant::ReservedUnknownTag(tag.clone()));
546                    }
547                }
548                MarkKind::Anchor { id } => {
549                    if id.is_empty() || !seen_anchor_ids.insert(id.as_str()) {
550                        return Err(Invariant::AnchorIdCollision { id: id.clone() });
551                    }
552                }
553                _ => {}
554            }
555        }
556        for line in &self.lines {
557            if let LineKind::Heading { level } = line.kind {
558                if !(1..=6).contains(&level) {
559                    return Err(Invariant::BadHeadingLevel(level));
560                }
561            }
562        }
563        // Table-cell marks: the prose range/zero-width/reserved-tag rules again,
564        // but each mark is bounded by its own cell's text length (in USV). Cells
565        // hold no `\n`, so the edge-on-newline rule does not apply.
566        let mut seen_ids = std::collections::HashSet::with_capacity(self.islands.len());
567        for island in &self.islands {
568            // Ids are deterministic, session-stable identities (hash input), so
569            // two islands may not share one. Uniqueness — not `id == isl-{i}` —
570            // is the invariant: edits keep an island's id across renumbers.
571            if !seen_ids.insert(island.id.as_str()) {
572                return Err(Invariant::IslandIdCollision {
573                    id: island.id.clone(),
574                });
575            }
576            // Structural shape (table column/row/aligns consistency, `\n`-free
577            // cells) before the per-cell mark ranges — a ragged island is
578            // ill-formed regardless of its marks.
579            if let Some(e) = crate::island::island_shape_error(island) {
580                return Err(e);
581            }
582            for (text, marks) in crate::island::island_cell_marks(island) {
583                let clen = text.chars().count();
584                for m in &marks {
585                    if m.start > m.end || m.end > clen {
586                        return Err(Invariant::MarkOutOfRange {
587                            start: m.start,
588                            end: m.end,
589                            len: clen,
590                        });
591                    }
592                    if m.start == m.end && m.kind.is_formatting() {
593                        return Err(Invariant::ZeroWidthFormatting { at: m.start });
594                    }
595                    if let MarkKind::Unknown { tag, .. } = &m.kind {
596                        if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
597                            return Err(Invariant::ReservedUnknownTag(tag.clone()));
598                        }
599                    }
600                }
601            }
602        }
603        Ok(())
604    }
605}
606
607/// Apply the three Spike-A rules and the canonical sort to a flat mark list.
608///
609/// 1. Same-kind formatting marks union when adjacent *or* overlapping.
610/// 2. Different-kind marks overlap freely (never split into runs).
611/// 3. Identity (and unknown) marks never merge.
612///
613/// Zero-width formatting marks are dropped (no-ops); zero-width anchors survive.
614pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
615    use std::collections::BTreeMap;
616
617    // Partition: formatting marks group by (ord, attrs_key) for union; identity
618    // and unknown pass through untouched (but zero-width formatting is dropped).
619    let mut groups: BTreeMap<(u8, String), Vec<(Usv, Usv)>> = BTreeMap::new();
620    let mut kind_of: BTreeMap<(u8, String), MarkKind> = BTreeMap::new();
621    let mut passthrough: Vec<Mark> = Vec::new();
622
623    for m in marks {
624        if m.kind.is_formatting() {
625            if m.start >= m.end {
626                continue; // drop zero-width / inverted formatting
627            }
628            let key = (m.kind.ord(), m.kind.attrs_key());
629            kind_of.entry(key.clone()).or_insert_with(|| m.kind.clone());
630            groups.entry(key).or_default().push((m.start, m.end));
631        } else {
632            passthrough.push(m);
633        }
634    }
635
636    let mut out: Vec<Mark> = Vec::new();
637    for (key, mut ranges) in groups {
638        ranges.sort_unstable();
639        let kind = kind_of.remove(&key).expect("kind recorded with group");
640        let mut cur = ranges[0];
641        for &(s, e) in &ranges[1..] {
642            if s <= cur.1 {
643                // adjacent (s == cur.1) or overlapping — union
644                cur.1 = cur.1.max(e);
645            } else {
646                out.push(Mark {
647                    start: cur.0,
648                    end: cur.1,
649                    kind: kind.clone(),
650                });
651                cur = (s, e);
652            }
653        }
654        out.push(Mark {
655            start: cur.0,
656            end: cur.1,
657            kind,
658        });
659    }
660    out.extend(passthrough);
661
662    // Canonical sort: (start, end, kind-ord, attrs). Key cached per mark so
663    // `attrs_key`'s allocation runs once each, not once per comparison.
664    out.sort_by_cached_key(|m| (m.start, m.end, m.kind.ord(), m.kind.attrs_key()));
665    // Drop byte-identical duplicates. Identity/unknown handles never *merge*
666    // (Spike-A rule 3), but two marks equal in range, kind, and attrs are the
667    // same handle recorded twice — redundant bytes, not two handles. The sort
668    // above makes any such pair adjacent, so `dedup` (structural `PartialEq`,
669    // order-independent for `Unknown` attrs under `preserve_order`) removes it.
670    out.dedup();
671    out
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn f(start: Usv, end: Usv, kind: MarkKind) -> Mark {
679        Mark { start, end, kind }
680    }
681
682    #[test]
683    fn is_blank_tracks_whitespace_and_islands() {
684        assert!(Content::empty().is_blank());
685        let mut ws = Content::empty();
686        ws.text = "  \n\t ".to_string();
687        ws.lines = vec![
688            Line {
689                kind: LineKind::Para,
690                containers: Vec::new(),
691                continues: false,
692            },
693            Line {
694                kind: LineKind::Para,
695                containers: Vec::new(),
696                continues: false,
697            },
698        ];
699        assert!(ws.is_blank(), "whitespace-only text is blank");
700
701        let mut has_text = Content::empty();
702        has_text.text = "x".to_string();
703        assert!(!has_text.is_blank());
704
705        // An island slot is not whitespace, so an island-bearing content is
706        // never blank even with no other text.
707        let mut island_only = Content::empty();
708        island_only.text = ISLAND_SLOT.to_string();
709        assert!(!island_only.is_blank());
710    }
711
712    #[test]
713    fn same_kind_adjacent_unions() {
714        // [0,3) strong + [3,6) strong -> [0,6) strong (rule 1, adjacency).
715        let got = normalize_marks(vec![f(3, 6, MarkKind::Strong), f(0, 3, MarkKind::Strong)]);
716        assert_eq!(got, vec![f(0, 6, MarkKind::Strong)]);
717    }
718
719    #[test]
720    fn same_kind_overlapping_unions() {
721        let got = normalize_marks(vec![f(0, 4, MarkKind::Emph), f(2, 7, MarkKind::Emph)]);
722        assert_eq!(got, vec![f(0, 7, MarkKind::Emph)]);
723    }
724
725    #[test]
726    fn different_kinds_overlap_freely() {
727        // Strong and emph over overlapping ranges stay two marks (rule 2).
728        let got = normalize_marks(vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]);
729        assert_eq!(
730            got,
731            vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]
732        );
733    }
734
735    #[test]
736    fn links_union_only_at_same_url() {
737        let a = MarkKind::Link { url: "a".into() };
738        let b = MarkKind::Link { url: "b".into() };
739        // Same url adjacent -> union; different url -> distinct.
740        let got = normalize_marks(vec![
741            f(0, 2, a.clone()),
742            f(2, 4, a.clone()),
743            f(4, 6, b.clone()),
744        ]);
745        assert_eq!(got, vec![f(0, 4, a), f(4, 6, b)]);
746    }
747
748    #[test]
749    fn identity_never_merges() {
750        // Two anchors over the same range are two distinct things (rule 3).
751        let a = MarkKind::Anchor { id: "c1".into() };
752        let b = MarkKind::Anchor { id: "c2".into() };
753        let got = normalize_marks(vec![f(3, 3, a.clone()), f(3, 3, b.clone())]);
754        assert_eq!(got.len(), 2);
755        assert!(got.contains(&f(3, 3, a)));
756        assert!(got.contains(&f(3, 3, b)));
757    }
758
759    #[test]
760    fn zero_width_formatting_dropped_zero_width_anchor_kept() {
761        let got = normalize_marks(vec![
762            f(2, 2, MarkKind::Strong),
763            f(2, 2, MarkKind::Anchor { id: "x".into() }),
764        ]);
765        assert_eq!(got, vec![f(2, 2, MarkKind::Anchor { id: "x".into() })]);
766    }
767
768    #[test]
769    fn empty_is_valid() {
770        assert_eq!(Content::empty().validate(), Ok(()));
771    }
772
773    #[test]
774    fn is_inline_accepts_empty_and_single_para() {
775        assert!(Content::empty().is_inline());
776        assert!(crate::import::from_markdown("just one line")
777            .unwrap()
778            .is_inline());
779        assert!(crate::import::from_markdown("a *bold* run")
780            .unwrap()
781            .is_inline());
782    }
783
784    #[test]
785    fn is_inline_rejects_blocks_containers_and_islands() {
786        // Two paragraphs → two Para lines.
787        assert!(!crate::import::from_markdown("one\n\ntwo")
788            .unwrap()
789            .is_inline());
790        // A heading is a non-Para line kind.
791        assert!(!crate::import::from_markdown("# heading")
792            .unwrap()
793            .is_inline());
794        // A list item sits in a container.
795        assert!(!crate::import::from_markdown("- item").unwrap().is_inline());
796    }
797
798    #[test]
799    fn validate_catches_slot_mismatch() {
800        let mut rt = Content::empty();
801        rt.text = "\u{FFFC}".into();
802        rt.lines = vec![Line {
803            kind: LineKind::Island,
804            containers: vec![],
805            continues: false,
806        }];
807        assert_eq!(
808            rt.validate(),
809            Err(Invariant::IslandSlotMismatch {
810                slots: 1,
811                islands: 0
812            })
813        );
814    }
815
816    #[test]
817    fn validate_catches_line_count() {
818        let mut rt = Content::empty();
819        rt.text = "a\nb".into(); // 2 segments, but 1 line
820        assert_eq!(
821            rt.validate(),
822            Err(Invariant::LineCountMismatch {
823                lines: 1,
824                segments: 2
825            })
826        );
827    }
828
829    #[test]
830    fn normalize_is_idempotent() {
831        let mut rt = Content::empty();
832        rt.text = "hello world".into();
833        rt.marks = vec![
834            f(6, 11, MarkKind::Strong),
835            f(0, 5, MarkKind::Strong),
836            f(0, 5, MarkKind::Emph),
837        ];
838        rt.normalize();
839        let once = rt.marks.clone();
840        rt.normalize();
841        assert_eq!(rt.marks, once);
842        assert_eq!(rt.validate(), Ok(()));
843    }
844
845    /// A table cell built with un-normalized marks (reversed order, an adjacent
846    /// same-kind pair, a zero-width formatting mark) canonicalizes to the same
847    /// marks whatever the input order — the live-model determinism invariant.
848    #[test]
849    fn table_cell_marks_normalize_and_are_idempotent() {
850        fn table(cell_marks: serde_json::Value) -> Content {
851            let mut rt = Content::empty();
852            rt.text = ISLAND_SLOT.to_string();
853            rt.lines = vec![Line {
854                kind: LineKind::Island,
855                containers: vec![],
856                continues: false,
857            }];
858            rt.islands = vec![Island {
859                id: "i".into(),
860                island_type: "table".into(),
861                props: serde_json::json!({
862                    "aligns": ["none"],
863                    "header": [{"text": "abcd", "marks": cell_marks}],
864                    "rows": [],
865                }),
866                loss: Loss::Lossless,
867            }];
868            rt
869        }
870        // Reversed order + adjacent same-kind pair (0..2)+(2..4) → unioned 0..4;
871        // a zero-width strong at 1 → dropped.
872        let mut a = table(serde_json::json!([
873            {"start": 2, "end": 4, "type": "strong"},
874            {"start": 1, "end": 1, "type": "strong"},
875            {"start": 0, "end": 2, "type": "strong"}
876        ]));
877        a.normalize();
878        assert_eq!(a.validate(), Ok(()));
879        let cell = &a.islands[0].props["header"][0];
880        assert_eq!(cell["marks"].as_array().unwrap().len(), 1);
881        assert_eq!(cell["marks"][0]["start"], 0);
882        assert_eq!(cell["marks"][0]["end"], 4);
883        // Same content, different input order → identical canonical bytes.
884        let mut b = table(serde_json::json!([
885            {"start": 0, "end": 2, "type": "strong"},
886            {"start": 2, "end": 4, "type": "strong"}
887        ]));
888        b.normalize();
889        assert_eq!(a.to_canonical_json(), b.to_canonical_json());
890        // Idempotent.
891        let once = a.to_canonical_json();
892        a.normalize();
893        assert_eq!(a.to_canonical_json(), once);
894    }
895
896    /// `validate` bounds a cell mark by its own cell's text length (in USV).
897    #[test]
898    fn validate_catches_cell_mark_out_of_range() {
899        let mut rt = Content::empty();
900        rt.text = ISLAND_SLOT.to_string();
901        rt.lines = vec![Line {
902            kind: LineKind::Island,
903            containers: vec![],
904            continues: false,
905        }];
906        rt.islands = vec![Island {
907            id: "i".into(),
908            island_type: "table".into(),
909            props: serde_json::json!({
910                "aligns": ["none"],
911                // "ab" is 2 USV; a mark ending at 5 runs past the cell.
912                "header": [{"text": "ab", "marks": [{"start": 0, "end": 5, "type": "strong"}]}],
913                "rows": [],
914            }),
915            loss: Loss::Lossless,
916        }];
917        assert_eq!(
918            rt.validate(),
919            Err(Invariant::MarkOutOfRange {
920                start: 0,
921                end: 5,
922                len: 2
923            })
924        );
925    }
926
927    /// A `Content` holding a single table island with the given props — the
928    /// shared fixture for the table-shape invariant tests.
929    fn table_rt(props: serde_json::Value) -> Content {
930        let mut rt = Content::empty();
931        rt.text = ISLAND_SLOT.to_string();
932        rt.lines = vec![Line {
933            kind: LineKind::Island,
934            containers: vec![],
935            continues: false,
936        }];
937        rt.islands = vec![Island {
938            id: "i".into(),
939            island_type: "table".into(),
940            props,
941            loss: Loss::Lossless,
942        }];
943        rt
944    }
945
946    fn cell(t: &str) -> serde_json::Value {
947        serde_json::json!({ "text": t, "marks": [] })
948    }
949
950    /// `validate` rejects a ragged body row, an `aligns`/column mismatch, and a
951    /// cell carrying a `\n` — the three table-shape invariants.
952    #[test]
953    fn validate_catches_table_shape() {
954        // Ragged row: header has 2 columns, the row has 3.
955        let rt = table_rt(serde_json::json!({
956            "aligns": ["none", "none"],
957            "header": [cell("a"), cell("b")],
958            "rows": [[cell("1"), cell("2"), cell("3")]],
959        }));
960        assert_eq!(
961            rt.validate(),
962            Err(Invariant::TableRaggedRow {
963                row: 0,
964                width: 3,
965                cols: 2
966            })
967        );
968
969        // aligns length differs from the column count.
970        let rt = table_rt(serde_json::json!({
971            "aligns": ["none"],
972            "header": [cell("a"), cell("b")],
973            "rows": [],
974        }));
975        assert_eq!(
976            rt.validate(),
977            Err(Invariant::TableAlignsMismatch { aligns: 1, cols: 2 })
978        );
979
980        // A `\n` in a cell (flat header-then-rows index 1 = the second header cell).
981        let rt = table_rt(serde_json::json!({
982            "aligns": ["none", "none"],
983            "header": [cell("a"), cell("b\nc")],
984            "rows": [],
985        }));
986        assert_eq!(rt.validate(), Err(Invariant::TableCellNewline { cell: 1 }));
987    }
988
989    /// `normalize` repairs every table-shape violation — pads the header and
990    /// short rows to the widest column count, syncs `aligns`, and rewrites a
991    /// cell `\n` to a space — so the result validates and is idempotent. This is
992    /// also the one-column-count unification: the widest row (3) drives the
993    /// header width, so the markdown (header-derived) and Typst (widest-row)
994    /// projections agree.
995    #[test]
996    fn normalize_repairs_table_shape() {
997        let mut rt = table_rt(serde_json::json!({
998            "aligns": ["none"],
999            "header": [cell("h")],
1000            "rows": [
1001                [cell("a"), cell("b"), cell("c")],
1002                [cell("d\ne")],
1003            ],
1004        }));
1005        rt.normalize();
1006        assert_eq!(rt.validate(), Ok(()));
1007
1008        let props = &rt.islands[0].props;
1009        assert_eq!(props["header"].as_array().unwrap().len(), 3);
1010        assert_eq!(props["aligns"].as_array().unwrap().len(), 3);
1011        for row in props["rows"].as_array().unwrap() {
1012            assert_eq!(row.as_array().unwrap().len(), 3);
1013        }
1014        // Padded aligns default to "none"; the padded cells are empty.
1015        assert_eq!(props["aligns"][2], serde_json::json!("none"));
1016        assert_eq!(props["header"][1]["text"], serde_json::json!(""));
1017        // The `\n` in "d\ne" became a space, preserving char count.
1018        assert_eq!(props["rows"][1][0]["text"], serde_json::json!("d e"));
1019
1020        // Idempotent on canonical bytes.
1021        let once = rt.to_canonical_json();
1022        rt.normalize();
1023        assert_eq!(rt.to_canonical_json(), once);
1024    }
1025
1026    /// An empty table (no header, no rows) is trivially well-formed: every width
1027    /// is zero, so no shape invariant fires and `normalize` leaves it alone.
1028    #[test]
1029    fn empty_table_is_valid() {
1030        let mut rt = table_rt(serde_json::json!({
1031            "aligns": [],
1032            "header": [],
1033            "rows": [],
1034        }));
1035        assert_eq!(rt.validate(), Ok(()));
1036        rt.normalize();
1037        assert_eq!(rt.validate(), Ok(()));
1038    }
1039
1040    /// A non-array `header` carries no cells: `validate` rejects it and
1041    /// `normalize` repairs it to an empty array (a zero-column table that then
1042    /// validates). Issue #904.
1043    #[test]
1044    fn non_array_table_header_is_rejected_then_repaired() {
1045        let mut rt = table_rt(serde_json::json!({
1046            "header": "oops",
1047            "aligns": [],
1048            "rows": [],
1049        }));
1050        assert_eq!(rt.validate(), Err(Invariant::TableHeaderNotArray));
1051        rt.normalize();
1052        assert_eq!(rt.validate(), Ok(()));
1053        assert_eq!(rt.islands[0].props["header"], serde_json::json!([]));
1054    }
1055
1056    /// Two islands sharing an `id` violate the minted-identity invariant.
1057    /// Import mints ids by index so never collides; a hand-built content can.
1058    /// Issue #903.
1059    #[test]
1060    fn duplicate_island_id_is_rejected() {
1061        let mut rt = Content::empty();
1062        rt.text = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}");
1063        rt.lines = vec![
1064            Line {
1065                kind: LineKind::Island,
1066                containers: vec![],
1067                continues: false,
1068            },
1069            Line {
1070                kind: LineKind::Island,
1071                containers: vec![],
1072                continues: false,
1073            },
1074        ];
1075        let table = |id: &str| Island {
1076            id: id.into(),
1077            island_type: "table".into(),
1078            props: serde_json::json!({ "header": [cell("h")], "aligns": ["none"], "rows": [] }),
1079            loss: Loss::Lossless,
1080        };
1081        rt.islands = vec![table("dup"), table("dup")];
1082        assert_eq!(
1083            rt.validate(),
1084            Err(Invariant::IslandIdCollision { id: "dup".into() })
1085        );
1086        // Distinct ids validate.
1087        rt.islands = vec![table("a"), table("b")];
1088        assert_eq!(rt.validate(), Ok(()));
1089    }
1090
1091    /// Two prose anchors sharing an `id` at different ranges violate the
1092    /// anchor-id uniqueness invariant, as does the empty id. (Byte-identical
1093    /// anchors `normalize` already dedupes; this is the surviving collision.)
1094    /// Issue #1039.
1095    #[test]
1096    fn duplicate_or_empty_anchor_id_is_rejected() {
1097        let mut rt = Content::empty();
1098        rt.text = "abcd".into();
1099        let anchor = |start, end, id: &str| Mark {
1100            start,
1101            end,
1102            kind: MarkKind::Anchor { id: id.into() },
1103        };
1104        // Same id at two ranges — `RemoveAnchor` can't disambiguate them.
1105        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "x")];
1106        assert_eq!(
1107            rt.validate(),
1108            Err(Invariant::AnchorIdCollision { id: "x".into() })
1109        );
1110        // Distinct ids over distinct ranges validate.
1111        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "y")];
1112        assert_eq!(rt.validate(), Ok(()));
1113        // The empty id is a degenerate handle.
1114        rt.marks = vec![anchor(0, 2, "")];
1115        assert_eq!(
1116            rt.validate(),
1117            Err(Invariant::AnchorIdCollision { id: String::new() })
1118        );
1119    }
1120
1121    /// `normalize` drops a byte-identical duplicate identity mark (same range,
1122    /// same id) — the same handle recorded twice is redundant, not two handles.
1123    /// Distinct-id anchors over the same range are kept. Issue #906.
1124    #[test]
1125    fn normalize_dedupes_identical_identity_marks() {
1126        let mut rt = Content::empty();
1127        rt.text = "abcd".into();
1128        let anchor = |id: &str| Mark {
1129            start: 0,
1130            end: 4,
1131            kind: MarkKind::Anchor { id: id.into() },
1132        };
1133        rt.marks = vec![anchor("x"), anchor("x")];
1134        rt.normalize();
1135        assert_eq!(rt.marks, vec![anchor("x")]);
1136        // Different ids over the same range are distinct handles — both survive.
1137        rt.marks = vec![anchor("x"), anchor("y")];
1138        rt.normalize();
1139        assert_eq!(rt.marks.len(), 2);
1140    }
1141}