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: 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;
14use std::borrow::Cow;
15
16/// A position in a [`Content`], counted in Unicode scalar values (USV): never
17/// bytes, never UTF-16 units. One astral char is 1 USV / 4 UTF-8 bytes / 2
18/// UTF-16 units. Conversions to/from the JS (UTF-16) and Rust (UTF-8)
19/// boundaries live in [`crate::usv`].
20pub type Usv = usize;
21
22/// U+FFFC OBJECT REPLACEMENT CHARACTER: the single-USV slot an island occupies
23/// in the content. One slot per island; every slot has a backing island. A stray
24/// slot (or a slot with no island) is an invariant violation.
25pub const ISLAND_SLOT: char = '\u{FFFC}';
26
27/// One content field as a content: the text plus the structure that rides on it.
28///
29/// Invariants (established once by import normalization, checked by
30/// [`Content::validate`]): the text holds no `\r` and no bidi controls; the
31/// count of [`ISLAND_SLOT`] equals `islands.len()`; `lines.len()` equals the
32/// number of `\n`-separated segments; marks are normalized (sorted, unioned).
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub struct Content {
36    /// The content. `\n` is a line boundary; [`ISLAND_SLOT`] is an island slot.
37    pub text: String,
38    /// One entry per `\n`-separated segment of `text`, in order. The line tree
39    /// is *derived* from this flat list plus each line's `containers` path: it
40    /// is never stored, so a split/join is a single-char edit with no identity
41    /// crisis (there are no paragraph IDs).
42    pub lines: Vec<Line>,
43    /// Marks over char ranges, kept normalized: sorted by
44    /// `(start, end, kind-ord, attrs)`, same-kind formatting marks unioned.
45    pub marks: Vec<Mark>,
46    /// One entry per [`ISLAND_SLOT`], in slot order (ascending char position).
47    pub islands: Vec<Island>,
48}
49
50/// A line's attributes: its block role plus the container path it sits in.
51#[derive(Debug, Clone, PartialEq)]
52#[non_exhaustive]
53pub struct Line {
54    pub kind: LineKind,
55    /// Ancestor containers, outermost first. A multi-paragraph list item is two
56    /// `Para` lines sharing one `[ListItem]` path; a paragraph in a quote in a
57    /// list item is `[ListItem, Quote]`.
58    pub containers: Vec<Container>,
59    /// Whether this line continues the previous line's *block* across a hard
60    /// line break (no paragraph break between), rather than starting a new
61    /// block. `false` = a new block (paragraph spacing on either side); `true` =
62    /// a within-block line break (markdown hard break; consecutive lines of one
63    /// code fence). The first line is always `false`. This is what keeps a hard
64    /// break (backend `#linebreak()`) distinct from a paragraph boundary through
65    /// the freeze, and what groups a code fence's lines without an
66    /// adjacency heuristic.
67    pub continues: bool,
68}
69
70impl Line {
71    /// A line of `kind` at the top level: no containers, starting a new block.
72    /// Both defaults are what the wire reads off an absent key (`containers`
73    /// empty, `continues` omitted); set either with
74    /// [`with_containers`](Self::with_containers) /
75    /// [`with_continues`](Self::with_continues).
76    pub fn new(kind: LineKind) -> Self {
77        Line {
78            kind,
79            containers: Vec::new(),
80            continues: false,
81        }
82    }
83
84    /// Set [`containers`](Self::containers), the ancestor path, outermost first.
85    pub fn with_containers(mut self, containers: Vec<Container>) -> Self {
86        self.containers = containers;
87        self
88    }
89
90    /// Set [`continues`](Self::continues): `true` makes this line a within-block
91    /// break off the previous one rather than a new block.
92    pub fn with_continues(mut self, continues: bool) -> Self {
93        self.continues = continues;
94        self
95    }
96}
97
98/// The block role of a line. The tree between lines is inferred: two adjacent
99/// lines with equal `kind`+`containers` are two blocks of that role (e.g. two
100/// paragraphs), never one.
101///
102/// **Open**, on the same terms as [`MarkKind`]: an unrecognized role round-trips
103/// as [`LineKind::Unknown`] and *projects* as [`LineKind::Para`], so adding a
104/// block construct (a callout, a footnote, a task item) is not a document schema
105/// event, an older reader renders the future construct as a plain paragraph
106/// instead of refusing the whole document, and the opaque tag+attrs still reach a
107/// reader that understands them (`DOCUMENT_STORAGE.md` § Open vocabularies).
108#[derive(Debug, Clone, PartialEq)]
109#[non_exhaustive]
110pub enum LineKind {
111    Para,
112    /// ATX/Setext heading, level 1..=6.
113    Heading {
114        level: u8,
115    },
116    /// A line of a code block. `lang` is the (sanitized) info string, shared by
117    /// every line of the same block.
118    Code {
119        lang: Option<String>,
120    },
121    /// A block-level island: the line's sole content is one [`ISLAND_SLOT`].
122    Island,
123    /// A thematic break (`---`/`***`/`___`). The line carries no text: the
124    /// break is the line itself, parallel to how an island's content is its
125    /// one slot char.
126    Rule,
127    /// Open-set escape hatch: a block role this build does not know,
128    /// round-tripped opaque and projected as [`LineKind::Para`]. Carries
129    /// arbitrary text, like the `Para` it projects as, so no
130    /// [`LineKindMismatch`] constrains it.
131    Unknown {
132        tag: String,
133        attrs: JsonValue,
134    },
135}
136
137impl LineKind {
138    /// Whether the line projects as a paragraph: [`LineKind::Para`] itself, or an
139    /// unknown role, which every projection renders as one. For the tests a
140    /// `match` cannot serve: a `matches!(kind, Para)` that special-cases the
141    /// paragraph (suppressing an empty block, say) reads as complete but drops
142    /// the open arm, and the two emitters then drift on the construct neither
143    /// knows.
144    ///
145    /// An exhaustive `match` keeps listing both arms, and in this crate the
146    /// compiler still polices that: `#[non_exhaustive]` does not apply within
147    /// the defining crate. It does apply to the typst backend, whose emitter
148    /// folds both arms into one wildcard; there the ladder, not the compiler,
149    /// is what keeps a new role rendering.
150    pub fn projects_as_para(&self) -> bool {
151        matches!(self, LineKind::Para | LineKind::Unknown { .. })
152    }
153}
154
155/// A container a line nests inside. The ancestor path is a `Vec<Container>`.
156///
157/// **Open**, on [`LineKind`]'s terms: an unrecognized container round-trips as
158/// [`Container::Unknown`] and projects *transparently*, its lines render at the
159/// enclosing level, with no prefix, no wrapper, and no grouping of their own.
160#[derive(Debug, Clone, PartialEq)]
161#[non_exhaustive]
162pub enum Container {
163    /// A list item. `ordered` distinguishes `1.` from `-`; `start` is the list's
164    /// first number (1 by default); `ordinal` is this item's 0-based index in
165    /// its list. Two *adjacent* lines belong to the same item iff their whole
166    /// container path (ordinals included) is equal, so a multi-paragraph item
167    /// is two lines sharing one `ListItem`, while the next item differs by
168    /// `ordinal`. (Identity is path **plus contiguity**: two sibling inner lists
169    /// under one outer item can produce equal first-item paths, distinguished
170    /// only by the non-adjacency of their runs.) Positional and deterministic,
171    /// no minted ids.
172    ListItem {
173        ordered: bool,
174        start: u64,
175        ordinal: u64,
176    },
177    /// A block quote. Adjacent lines sharing `[Quote]` are one multi-paragraph
178    /// quote; two adjacent separate quotes are not distinguished (they merge on
179    /// round-trip: a documented canonicalization).
180    Quote,
181    /// Open-set escape hatch: a container this build does not know, kept in the
182    /// path so it round-trips, transparent to both projections. Two adjacent
183    /// lines sit in the same one iff their whole `(tag, attrs)` is equal, the
184    /// path-plus-contiguity rule the known containers use.
185    Unknown {
186        tag: String,
187        attrs: JsonValue,
188    },
189}
190
191/// A mark over a char range `[start, end)`. `start == end` (zero-width) is legal
192/// only for [`MarkKind::Anchor`]; normalization drops zero-width formatting.
193#[derive(Debug, Clone, PartialEq)]
194#[non_exhaustive]
195pub struct Mark {
196    pub start: Usv,
197    pub end: Usv,
198    pub kind: MarkKind,
199}
200
201impl Mark {
202    /// A mark of `kind` over `[start, end)`. The three fields are the whole of a
203    /// mark: nothing optional sits beside them.
204    pub fn new(start: Usv, end: Usv, kind: MarkKind) -> Self {
205        Mark { start, end, kind }
206    }
207}
208
209/// The mark set, **open**: an unknown kind round-trips as [`MarkKind::Unknown`],
210/// absorbed as a new *type*, never a changed semantics of a known one. Two
211/// algebra classes: formatting is a property of a range (two coincident are
212/// redundant); identity is a handle (two over the same range are two things).
213#[derive(Debug, Clone, PartialEq)]
214#[non_exhaustive]
215pub enum MarkKind {
216    // Formatting: round-trippable projection marks. `is_formatting()`.
217    Strong,
218    Emph,
219    Underline,
220    Strike,
221    Code,
222    Link {
223        url: String,
224    },
225    // Identity: a handle, not a property. Never merged, may be zero-width.
226    /// A comment thread or stable anchor, carried by id and rebased across
227    /// edits like any position. The id is caller-supplied, unique per `Content`,
228    /// opaque and invariant while the mark lives; positions rebase, the id never
229    /// does, and moved-and-rewritten text drops the mark whole
230    /// (`DOCUMENT_STORAGE.md` § Anchor-id identity). No markdown projection
231    /// (omitted on export; survives via diff-rebase).
232    Anchor {
233        id: String,
234    },
235    // Open-set escape hatch: an unknown mark type, round-tripped opaque.
236    Unknown {
237        tag: String,
238        attrs: JsonValue,
239    },
240}
241
242/// A structured object with no honest text encoding (a table, figure, or future
243/// embed) occupying one [`ISLAND_SLOT`] in the content.
244#[derive(Debug, Clone, PartialEq)]
245#[non_exhaustive]
246pub struct Island {
247    /// Deterministically minted, session-stable id: `isl-{n}` by import
248    /// position (`import::mint_island`). Part of the canonical form and thus
249    /// hash input; deterministic by contract, never ambient, so equal content
250    /// hashes equal (`DOCUMENT_STORAGE.md` § Island-id determinism). Edits keep
251    /// it stable rather than re-deriving it, so [`Content::validate`] enforces
252    /// uniqueness, not positional equality.
253    pub id: String,
254    /// Island type discriminator (`"table"`, `"image"`, …). Unknown types
255    /// round-trip opaque.
256    pub island_type: String,
257    /// Typed payload. Recursively key-sorted by normalization so it hashes
258    /// deterministically despite `serde_json`'s `preserve_order`.
259    pub props: JsonValue,
260    /// How faithfully the markdown projection can carry this island.
261    pub loss: Loss,
262}
263
264impl Island {
265    /// An island of `island_type` under `id`, carrying no payload and claiming
266    /// no projection loss. Both defaults are what the wire reads off an absent
267    /// key: `props` absent is `Null`, and a missing `loss` is the faithful
268    /// class, which is what an island with no loss recorded means. Set either
269    /// with [`with_props`](Self::with_props) / [`with_loss`](Self::with_loss).
270    pub fn new(id: String, island_type: String) -> Self {
271        Island {
272            id,
273            island_type,
274            props: JsonValue::Null,
275            loss: Loss::LOSSLESS,
276        }
277    }
278
279    /// Set [`props`](Self::props), the typed payload.
280    pub fn with_props(mut self, props: JsonValue) -> Self {
281        self.props = props;
282        self
283    }
284
285    /// Set [`loss`](Self::loss), how faithfully markdown carries this island.
286    pub fn with_loss(mut self, loss: Loss) -> Self {
287        self.loss = loss;
288        self
289    }
290}
291
292/// The markdown-projection loss class of an island: a **description** of how
293/// faithfully the projection carries it, for a consumer to surface (a caller
294/// warned that a form field silently dropped a table). It is not a
295/// switch: [`crate::export::to_markdown`] dispatches on
296/// [`Island::island_type`], never on this.
297///
298/// Open on [`Island::island_type`]'s terms: the wire string *is* the stored
299/// value, and [`Fidelity`] is the closed view over it. A class this build lacks
300/// is carried verbatim, so a reader that merely opens a document does not move
301/// its content hash (`DOCUMENT_STORAGE.md` § Byte-stability).
302///
303/// One value per wire string, so the encoding is injective: a built-in's name
304/// spells that built-in and nothing else, and the reserved-name rule the
305/// payload-carrying axes need ([`Invariant::ReservedUnknownTag`] and its two
306/// siblings) has nothing to guard here.
307///
308/// Read fidelity through [`Loss::fidelity`], never by comparing against
309/// [`Loss::LOSSLESS`]: an uninterpretable class degrades to
310/// [`Fidelity::Unrepresentable`], so nothing is claimed to carry faithfully on
311/// the strength of a name this build cannot read.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct Loss(Cow<'static, str>);
314
315impl Loss {
316    /// Markdown carries it faithfully (round-trips identically).
317    pub const LOSSLESS: Loss = Loss(Cow::Borrowed(Fidelity::Lossless.as_str()));
318    /// Markdown carries an approximation (round-trips visibly, not identically).
319    pub const DEGRADED: Loss = Loss(Cow::Borrowed(Fidelity::Degraded.as_str()));
320    /// No markdown encoding: what an island type with no projection carries.
321    pub const UNREPRESENTABLE: Loss = Loss(Cow::Borrowed(Fidelity::Unrepresentable.as_str()));
322
323    /// Wrap a wire class. Every string is a class, uninterpretable ones included;
324    /// [`Loss::fidelity`] is where that is resolved.
325    ///
326    /// An interpretable class borrows its `'static` spelling, so decoding an
327    /// island allocates only for the uninterpretable. Equality is by name either
328    /// way, so `Loss::new("lossless") == Loss::LOSSLESS`.
329    pub fn new(class: &str) -> Loss {
330        match Fidelity::parse(class) {
331            Some(f) => Loss(Cow::Borrowed(f.as_str())),
332            None => Loss(Cow::Owned(class.to_string())),
333        }
334    }
335
336    /// The wire discriminator, and the canonical-form bytes.
337    pub fn as_str(&self) -> &str {
338        &self.0
339    }
340
341    /// The fidelity this class describes, with an uninterpretable class degraded
342    /// to the safe end.
343    ///
344    /// Carrying the raw class preserves the byte round-trip. Reading it through
345    /// here keeps that carriage from being mistaken for a claim about the
346    /// projection.
347    pub fn fidelity(&self) -> Fidelity {
348        Fidelity::parse(self.as_str()).unwrap_or(Fidelity::Unrepresentable)
349    }
350}
351
352/// How faithfully the markdown projection carries an island: the closed view
353/// over [`Loss`], and what a consumer switches on.
354///
355/// The [`KnownIslandType`](crate::island::KnownIslandType) twin, one axis over,
356/// and exhaustive for the same reason: a consumer laddering on fidelity has no
357/// safe fallthrough for a rung it does not know, so a new rung is a major bump
358/// rather than a silent gap.
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum Fidelity {
361    /// Round-trips identically.
362    Lossless,
363    /// Round-trips visibly, not identically.
364    Degraded,
365    /// No markdown encoding, and where an uninterpretable class lands.
366    Unrepresentable,
367}
368
369impl Fidelity {
370    /// Every level, faithful first. The one enumeration point, so a reader that
371    /// needs the closed set whole (the WASM surface's class union, say) asks
372    /// rather than re-spelling it.
373    pub const ALL: &'static [Fidelity] = &[
374        Fidelity::Lossless,
375        Fidelity::Degraded,
376        Fidelity::Unrepresentable,
377    ];
378
379    /// The wire class naming this level: the one place a class is spelled, which
380    /// [`Loss`]'s consts and [`Fidelity::parse`] both read.
381    pub const fn as_str(self) -> &'static str {
382        match self {
383            Self::Lossless => "lossless",
384            Self::Degraded => "degraded",
385            Self::Unrepresentable => "unrepresentable",
386        }
387    }
388
389    /// Parse a wire class into the closed view. `None` is the open-set escape
390    /// hatch (an uninterpretable class), which [`Loss::fidelity`] reads as
391    /// [`Unrepresentable`](Fidelity::Unrepresentable).
392    pub fn parse(class: &str) -> Option<Fidelity> {
393        Self::ALL.iter().copied().find(|f| f.as_str() == class)
394    }
395}
396
397impl MarkKind {
398    /// Formatting marks are a property of a range and union when coincident;
399    /// identity/unknown marks are handles and never merge (Spike-A rules).
400    ///
401    /// Class membership is stored meaning, not presentation: promoting an
402    /// open-set tag *into* this class starts unioning adjacent runs that
403    /// round-tripped as two marks, so the promotion moves the canonical bytes of
404    /// documents nobody edited (`DOCUMENT_STORAGE.md` § Promoting a vocabulary
405    /// member).
406    pub fn is_formatting(&self) -> bool {
407        matches!(
408            self,
409            MarkKind::Strong
410                | MarkKind::Emph
411                | MarkKind::Underline
412                | MarkKind::Strike
413                | MarkKind::Code
414                | MarkKind::Link { .. }
415        )
416    }
417
418    /// Total order over kinds for the canonical sort tie-break, after
419    /// `(start, end)`. Stable across releases: part of the freeze.
420    ///
421    /// A new variant takes the slot immediately **before** [`MarkKind::Unknown`],
422    /// pushing `Unknown` up by one. That is the only placement where a build that
423    /// knows the type and a build that reads it as `Unknown` order it identically
424    /// against every built-in; anywhere else is two canonical forms for one
425    /// document, one per reader (`DOCUMENT_STORAGE.md` § Promoting a vocabulary
426    /// member). The block axes sort by nothing, so the rule is the mark axis'
427    /// alone.
428    pub fn ord(&self) -> u8 {
429        match self {
430            MarkKind::Strong => 0,
431            MarkKind::Emph => 1,
432            MarkKind::Underline => 2,
433            MarkKind::Strike => 3,
434            MarkKind::Code => 4,
435            MarkKind::Link { .. } => 5,
436            MarkKind::Anchor { .. } => 6,
437            MarkKind::Unknown { .. } => 7,
438        }
439    }
440
441    /// Attribute tie-break string, appended after `ord` in the canonical sort so
442    /// two marks that differ only in attrs order deterministically. Also the
443    /// grouping key for same-kind union (two formatting marks union only when
444    /// this matches; e.g. two `link`s union only at the same url).
445    pub fn attrs_key(&self) -> String {
446        match self {
447            MarkKind::Link { url } => url.clone(),
448            MarkKind::Anchor { id } => id.clone(),
449            MarkKind::Unknown { tag, attrs } => {
450                // Attrs sorted so the key is order-insensitive.
451                format!("{}\u{0}{}", tag, canonical_json_string(attrs))
452            }
453            _ => String::new(),
454        }
455    }
456}
457
458/// A `serde_json::Value` rendered to a string with object keys recursively
459/// sorted: order-insensitive, so it is a stable comparison/grouping key.
460fn canonical_json_string(v: &JsonValue) -> String {
461    serde_json::to_string(&sort_keys_owned(v.clone())).unwrap_or_default()
462}
463
464/// Whether every object in `v` already has its keys in ascending order,
465/// recursively: the cheap allocation-free check that lets a re-normalize skip
466/// rebuilding an already-canonical `props`/`attrs` tree.
467/// Once normalized, an untouched tree stays sorted, so a per-keystroke
468/// re-normalize pays a scan instead of a full clone.
469pub(crate) fn is_value_key_sorted(v: &JsonValue) -> bool {
470    match v {
471        JsonValue::Array(items) => items.iter().all(is_value_key_sorted),
472        JsonValue::Object(map) => {
473            map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
474                && map.values().all(is_value_key_sorted)
475        }
476        _ => true,
477    }
478}
479
480/// `true` when `v` nests deeper than `max` container levels: the guard that
481/// keeps the recursive walkers above ([`is_value_key_sorted`], [`sort_keys_owned`])
482/// and `Value`'s own `Drop` inside a bounded frame count.
483///
484/// The walk is iterative (explicit stack), so the check itself cannot overflow on
485/// the adversarially deep input it exists to detect. The unit is **container
486/// levels**, not nodes: only arrays and objects are charged a level and the
487/// scalar leaf at the bottom of a chain is never checked, so `max` nested
488/// containers are accepted whether the deepest holds a scalar, is empty, or holds
489/// another container, and `max + 1` is rejected in every case. Level-charging
490/// matches [`quillmark_core::json_depth_exceeds`], the document-side twin, so the
491/// two boundaries reject the identical shape.
492pub(crate) fn json_depth_exceeds(v: &JsonValue, max: usize) -> bool {
493    // (value, depth) pairs; depth counts container levels entered.
494    let mut stack: Vec<(&JsonValue, usize)> = vec![(v, 0)];
495    while let Some((v, depth)) = stack.pop() {
496        match v {
497            JsonValue::Array(items) => {
498                if depth + 1 > max {
499                    return true;
500                }
501                stack.extend(items.iter().map(|c| (c, depth + 1)));
502            }
503            JsonValue::Object(map) => {
504                if depth + 1 > max {
505                    return true;
506                }
507                stack.extend(map.values().map(|c| (c, depth + 1)));
508            }
509            _ => {}
510        }
511    }
512    false
513}
514
515/// [`json_depth_exceeds`] against [`MAX_JSON_DEPTH`](crate::MAX_JSON_DEPTH) as an
516/// [`Invariant`] result, `what` naming the bag. The one spelling shared by
517/// [`Content::validate`] and the decoders in [`crate::serial`], so the wire and
518/// in-process readings of the limit cannot drift.
519pub(crate) fn check_json_depth(v: &JsonValue, what: &'static str) -> Result<(), Invariant> {
520    if json_depth_exceeds(v, crate::MAX_JSON_DEPTH) {
521        return Err(Invariant::JsonTooDeep {
522            what,
523            max: crate::MAX_JSON_DEPTH,
524        });
525    }
526    Ok(())
527}
528
529/// Put `v` in canonical key order, rebuilding it only when a key is actually out
530/// of order: an untouched tree (a pure text splice) stays sorted, so the
531/// per-keystroke path pays the scan and skips the deep clone.
532pub(crate) fn canonicalize_keys(v: &mut JsonValue) {
533    if !is_value_key_sorted(v) {
534        *v = sort_keys_owned(std::mem::take(v));
535    }
536}
537
538/// The crate's one key sorter: reorder every object's keys by **moving** each
539/// entry into a freshly key-sorted map, recursively. Pins island `props` (and
540/// unknown line/container/mark `attrs`) against `preserve_order` leaking
541/// insertion order into the canonical bytes / content hash. Re-sorting into a
542/// new `serde_json::Map` (not sorting in place) keeps that independent of
543/// whether `serde_json`'s `preserve_order` feature is on in the crate graph.
544///
545/// The fixed struct keys land alphabetically and an already-sorted
546/// `props`/`attrs` re-sorts to itself. The leaves (the `text` string, mark
547/// attrs, arrays) move rather than deep-clone, so a tree built once by
548/// `to_value` is canonicalized without a second full clone, and the encoders
549/// emit their payload bags verbatim, one pass over the finished tree rather
550/// than one per bag.
551pub(crate) fn sort_keys_owned(v: JsonValue) -> JsonValue {
552    match v {
553        JsonValue::Array(items) => {
554            JsonValue::Array(items.into_iter().map(sort_keys_owned).collect())
555        }
556        JsonValue::Object(map) => {
557            let mut entries: Vec<(String, JsonValue)> = map.into_iter().collect();
558            entries.sort_by(|a, b| a.0.cmp(&b.0));
559            let mut out = serde_json::Map::with_capacity(entries.len());
560            for (k, child) in entries {
561                out.insert(k, sort_keys_owned(child));
562            }
563            JsonValue::Object(out)
564        }
565        other => other,
566    }
567}
568
569/// Ways a [`Content`] can violate its invariants. Returned by
570/// [`Content::validate`]; import normalization guarantees none of these.
571#[derive(Debug, Clone, PartialEq, Eq)]
572#[non_exhaustive]
573pub enum Invariant {
574    /// `\r` in the text (line endings must be normalized to `\n`).
575    CarriageReturn,
576    /// A bidi formatting control in the text.
577    BidiControl(char),
578    /// `island_slot_count != islands.len()`.
579    IslandSlotMismatch { slots: usize, islands: usize },
580    /// `lines.len() != newline_segment_count`.
581    LineCountMismatch { lines: usize, segments: usize },
582    /// A mark range runs past the content or is inverted (`start > end`).
583    MarkOutOfRange { start: Usv, end: Usv, len: Usv },
584    /// A zero-width formatting mark survived normalization.
585    ZeroWidthFormatting { at: Usv },
586    /// A heading level outside 1..=6.
587    BadHeadingLevel(u8),
588    /// The first line has `continues: true` (nothing precedes it to continue).
589    FirstLineContinues,
590    /// An [`MarkKind::Unknown`] reused a reserved built-in `type` name.
591    ReservedUnknownTag(String),
592    /// A [`LineKind::Unknown`] reused a reserved built-in `kind` name: its
593    /// serialization would parse back as the built-in, dropping its attrs.
594    ReservedUnknownLineKind(String),
595    /// A [`Container::Unknown`] reused a reserved built-in `container` name, the
596    /// same non-injectivity as [`Invariant::ReservedUnknownLineKind`].
597    ReservedUnknownContainer(String),
598    /// A formatting mark edge sits on a `\n` (normalization should have trimmed
599    /// it): a hand-built content that skipped `normalize`.
600    MarkEdgeOnNewline { at: Usv },
601    /// A table island's `aligns` length differs from its column count (the
602    /// header width). `normalize` syncs `aligns` to the column count.
603    TableAlignsMismatch { aligns: usize, cols: usize },
604    /// A table island body row's width differs from the column count (the header
605    /// width). `normalize` pads short rows (and the header) to the widest.
606    TableRaggedRow { row: usize, width: usize, cols: usize },
607    /// A table cell's text carries a `\n`: cells are single-line (a newline
608    /// would break the exported table). `cell` is the flat header-then-rows
609    /// index; `normalize` rewrites the newline to a space.
610    TableCellNewline { cell: usize },
611    /// Two islands share an `id`. Ids are deterministic, session-stable
612    /// identities (hash input, so never ambient); import mints them by index so
613    /// they never collide, but a hand-built or round-tripped content can.
614    /// Downstream code that keys islands by id would otherwise silently pick the
615    /// wrong one. Uniqueness is the id invariant `validate` enforces: positional
616    /// equality is not, since edits keep an island's id stable across renumbers.
617    IslandIdCollision { id: String },
618    /// Two prose anchors share an `id`, or one carries the empty id. An anchor
619    /// id is a caller-supplied, opaque handle, unique per `Content` (hash input,
620    /// never ambient in the twin's sense; `DOCUMENT_STORAGE.md` § Anchor-id
621    /// identity). `RemoveAnchor { id }` retains-out *every* match, so a shared id
622    /// makes removing one destroy both; the empty id is a degenerate handle.
623    /// Scope is prose marks: cell anchors are outside the op surface.
624    AnchorIdCollision { id: String },
625    /// A table island's `header` prop is present but not a JSON array: it
626    /// cannot carry column cells. `normalize` rewrites a non-array header to an
627    /// empty array (a zero-column, content-free table).
628    TableHeaderNotArray,
629    /// A line's [`LineKind`] contradicts its text. Export trusts the kind and
630    /// never re-reads the segment, so an unchecked mismatch is silent text loss
631    /// (an `Island`-tagged prose line projects to its resolved island alone).
632    LineKindMismatch { line: usize, mismatch: LineKindMismatch },
633    /// A line's container path is nested deeper than
634    /// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH). Both
635    /// emitters recurse one frame per container, so an unbounded path overflows
636    /// the stack; import caps it, and this is the same cap for the content that
637    /// never went through import (a decoded blob, a hand-built value).
638    NestingTooDeep {
639        line: usize,
640        depth: usize,
641        max: usize,
642    },
643    /// An opaque JSON payload (an island's `props`, an unknown line/container/
644    /// mark's `attrs`) nests deeper than [`MAX_JSON_DEPTH`](crate::MAX_JSON_DEPTH):
645    /// [`Invariant::NestingTooDeep`] on the payload axis. `what` names the bag.
646    /// No true depth: the check bails at the first over-deep container rather
647    /// than measuring past the limit.
648    JsonTooDeep { what: &'static str, max: usize },
649}
650
651/// The way a line's text can contradict its [`LineKind`]. `Para` and `Heading`
652/// carry arbitrary text including slots (an inline image is a slot in a `Para`),
653/// so only the three kinds whose contract *names* their content constrain it.
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655#[non_exhaustive]
656pub enum LineKindMismatch {
657    /// [`LineKind::Island`] whose text is not exactly one [`ISLAND_SLOT`].
658    IslandNotOneSlot,
659    /// [`LineKind::Rule`] carrying text: the break is the line itself.
660    RuleNotEmpty,
661    /// [`LineKind::Code`] carrying an [`ISLAND_SLOT`]. A fence emits its text
662    /// verbatim, so the slot lands raw in the output and re-imports as nothing:
663    /// the island and its slot both vanish.
664    CodeHasSlot,
665}
666
667/// How a line's text contradicts `kind`, if it does, the single reading behind
668/// the [`Invariant::LineKindMismatch`] and
669/// [`ApplyError::LineKindMismatch`](crate::ops::ApplyError::LineKindMismatch)
670/// twins, so the validate-time and op-time checks cannot drift.
671pub fn line_kind_mismatch(kind: &LineKind, seg: &str) -> Option<LineKindMismatch> {
672    match kind {
673        LineKind::Island => {
674            let mut chars = seg.chars();
675            match (chars.next(), chars.next()) {
676                (Some(ISLAND_SLOT), None) => None,
677                _ => Some(LineKindMismatch::IslandNotOneSlot),
678            }
679        }
680        LineKind::Rule if !seg.is_empty() => Some(LineKindMismatch::RuleNotEmpty),
681        LineKind::Code { .. } if seg.contains(ISLAND_SLOT) => Some(LineKindMismatch::CodeHasSlot),
682        _ => None,
683    }
684}
685
686impl Content {
687    /// The text and its per-line attributes: what a content always carries.
688    /// Marks and islands start empty and have [`with_marks`](Self::with_marks) /
689    /// [`with_islands`](Self::with_islands) beside them.
690    ///
691    /// Constructing does not normalize or check: the invariants in this type's
692    /// docs are the caller's until [`validate`](Self::validate) runs. The codecs
693    /// ([`crate::import`], [`Content::from_canonical_json`]) establish them, and
694    /// are what a consumer normally builds through.
695    pub fn new(text: String, lines: Vec<Line>) -> Self {
696        Content {
697            text,
698            lines,
699            marks: Vec::new(),
700            islands: Vec::new(),
701        }
702    }
703
704    /// Set [`marks`](Self::marks), the range-anchored marks over the text.
705    pub fn with_marks(mut self, marks: Vec<Mark>) -> Self {
706        self.marks = marks;
707        self
708    }
709
710    /// Set [`islands`](Self::islands), one per [`ISLAND_SLOT`] in slot order.
711    pub fn with_islands(mut self, islands: Vec<Island>) -> Self {
712        self.islands = islands;
713        self
714    }
715
716    /// An empty content: one empty `Para` line, no marks, no islands.
717    pub fn empty() -> Self {
718        Content::new(String::new(), vec![Line::new(LineKind::Para)])
719    }
720
721    /// Total length in USV.
722    pub fn len_usv(&self) -> Usv {
723        self.text.chars().count()
724    }
725
726    /// Whether this content satisfies the `richtext(inline)` constraint: exactly
727    /// one `Para` line, sitting in no container, with no islands. A single line
728    /// can never `continues` (line 0 is always `false`), so that dimension is
729    /// implied. [`Content::empty`] is inline (one empty `Para`), so a blank or
730    /// zero-filled inline field passes.
731    pub fn is_inline(&self) -> bool {
732        self.islands.is_empty()
733            && self.lines.len() == 1
734            && self.lines[0].kind == LineKind::Para
735            && self.lines[0].containers.is_empty()
736    }
737
738    /// Whether this content satisfies the `plaintext` constraint: no marks, no
739    /// islands, and every line is a plain `Para` sitting in no container. It is
740    /// the multi-line generalization of [`is_inline`](Self::is_inline) (which
741    /// additionally pins the content to one line) with the mark/island exclusion
742    /// made explicit: a plaintext value carries prose the author navigates but
743    /// no formatting. `continues` is unconstrained: a lone `\n` may be a
744    /// within-paragraph break. [`Content::empty`] is plain.
745    ///
746    /// This is the plaintext analogue of `is_inline`, enforced at coercion and
747    /// validation with the `NotPlain` error; the distinguishing property of
748    /// plaintext over `richtext { marks: [] }` is the *literal* codec
749    /// ([`crate::import::from_plaintext`]), not this predicate.
750    pub fn is_plain(&self) -> bool {
751        self.marks.is_empty()
752            && self.islands.is_empty()
753            && self
754                .lines
755                .iter()
756                .all(|l| l.kind == LineKind::Para && l.containers.is_empty())
757    }
758
759    /// Whether the content carries no renderable content: the text is empty or
760    /// whitespace-only. An island slot ([`ISLAND_SLOT`], U+FFFC) is not
761    /// whitespace, so an island-bearing content is never blank. Body-disabled
762    /// validation and round-trip emit key on it.
763    pub fn is_blank(&self) -> bool {
764        self.text.trim().is_empty()
765    }
766
767    /// Number of `\n`-separated segments: the required `lines.len()`.
768    pub fn segment_count(&self) -> usize {
769        self.text.chars().filter(|c| *c == '\n').count() + 1
770    }
771
772    /// Normalize marks in place: drop zero-width formatting, union same-kind
773    /// formatting that is adjacent or overlapping, recursively key-sort island
774    /// props and unknown-mark attrs, then sort marks canonically. Idempotent:
775    /// the fixed point the canonical serialization commits to.
776    pub fn normalize(&mut self) {
777        // Line kinds whose contract names their content, against the content
778        // they now hold. A splice writes text, never kinds: typing into a table
779        // line leaves it `Island` over prose, joining a fence to an image line
780        // leaves it `Code` over a slot, and export reads the kind and not the
781        // text, so the un-repaired line projects its content away. Demote to
782        // `Para`, the kind that carries anything (an inline island is a slot in a
783        // `Para`), which is what re-importing the line's own markdown yields.
784        // The repair-side twin of the [`Invariant::LineKindMismatch`] check; the
785        // deliberate mis-tag is refused up front instead
786        // ([`ApplyError::LineKindMismatch`](crate::ops::ApplyError::LineKindMismatch)),
787        // since silently undoing an op the caller asked for is worse than an error.
788        // The same pass canonicalizes the open block vocabulary's opaque `attrs`:
789        // it is hash input like every other field, so two equal contents whose
790        // unknown lines were built key-reversed must not serialize to different
791        // bytes. A demoted line is `Para`, never `Unknown`, so the two are
792        // independent.
793        for (line, seg) in self.lines.iter_mut().zip(self.text.split('\n')) {
794            if line_kind_mismatch(&line.kind, seg).is_some() {
795                line.kind = LineKind::Para;
796            }
797            if let LineKind::Unknown { attrs, .. } = &mut line.kind {
798                canonicalize_keys(attrs);
799            }
800            for c in &mut line.containers {
801                if let Container::Unknown { attrs, .. } = c {
802                    canonicalize_keys(attrs);
803                }
804            }
805        }
806        // Islands: canonicalize props key order. A table island's cells carry
807        // inline `{text, marks}`; repair its shape (pad the header/rows/aligns to
808        // one column count, rewrite any cell `\n` to a space) and canonicalize
809        // each cell's marks (sort, union, drop zero-width) first so equal cells
810        // serialize to equal bytes and `validate` holds: the props are
811        // otherwise opaque here.
812        for island in &mut self.islands {
813            crate::island::normalize_island_structure(island);
814            canonicalize_keys(&mut island.props);
815        }
816        for mark in &mut self.marks {
817            if let MarkKind::Unknown { attrs, .. } = &mut mark.kind {
818                canonicalize_keys(attrs);
819            }
820        }
821        // A formatting mark's edges never sit on a line boundary: markdown can't
822        // bold a `\n`, so two producers that disagree only about whether the
823        // boundary is "inside" the mark must canonicalize to the same bounds.
824        // Trim leading/trailing `\n` (interior boundaries are kept: a mark may
825        // legitimately span lines). Zero-width results are dropped below.
826        // Skip the full-text char collection when nothing needs trimming.
827        if self.marks.iter().any(|m| m.kind.is_formatting()) {
828            let chars: Vec<char> = self.text.chars().collect();
829            for m in &mut self.marks {
830                if m.kind.is_formatting() {
831                    while m.start < m.end && chars.get(m.start) == Some(&'\n') {
832                        m.start += 1;
833                    }
834                    while m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
835                        m.end -= 1;
836                    }
837                }
838            }
839        }
840        self.marks = normalize_marks(std::mem::take(&mut self.marks));
841    }
842
843    /// Mark `type` names the projection reserves; an [`MarkKind::Unknown`] may
844    /// not reuse one (its serialization would parse back as the built-in,
845    /// silently dropping its attrs: non-injective).
846    ///
847    /// Two enforcement points, on two lanes. [`Content::validate`] catches an
848    /// in-process Rust construction. The wire never reaches it: a decoder resolves
849    /// the built-in name before the `Unknown` fallthrough, so a reserved tag
850    /// *becomes* the built-in rather than arriving as an `Unknown`. The authored
851    /// lane therefore rejects the shape up front
852    /// ([`serial::from_authored_value`](crate::serial::from_authored_value) for
853    /// whole content; [`ops::mark_op_from_value`](crate::ops::mark_op_from_value)
854    /// and [`ops::line_op_from_value`](crate::ops::line_op_from_value) for the op
855    /// wire), while storage decode stays lenient by design; see there.
856    ///
857    /// This list and its two siblings are re-spelled by hand on the TypeScript
858    /// surface: the unions in `crates/bindings/wasm/src/engine.rs` and the
859    /// `isUnknown*` guards' tables in
860    /// `crates/bindings/wasm/runtime/runtime.js`. Both are pinned to these
861    /// constants by `crates/bindings/wasm/tests/known_names_drift.rs`.
862    /// Slices, not arrays, on all three: an array's length is part of its type,
863    /// and promoting a name into the projection is the motion these lists exist
864    /// to absorb.
865    pub const RESERVED_MARK_TYPES: &'static [&'static str] = &[
866        "strong",
867        "emph",
868        "underline",
869        "strike",
870        "code",
871        "link",
872        "anchor",
873    ];
874
875    /// Line `kind` names the projection reserves: the [`LineKind`] twin of
876    /// [`RESERVED_MARK_TYPES`](Self::RESERVED_MARK_TYPES), for the same
877    /// injectivity reason.
878    pub const RESERVED_LINE_KINDS: &'static [&'static str] =
879        &["para", "heading", "code", "island", "rule"];
880
881    /// Container names the projection reserves: the [`Container`] twin of
882    /// [`RESERVED_MARK_TYPES`](Self::RESERVED_MARK_TYPES).
883    pub const RESERVED_CONTAINERS: &'static [&'static str] = &["list_item", "quote"];
884
885    /// Check every invariant. `Ok(())` on a well-formed content. Import
886    /// guarantees this; a hand-built content should be run through it in tests.
887    pub fn validate(&self) -> Result<(), Invariant> {
888        let mut slots = 0usize;
889        for c in self.text.chars() {
890            if c == '\r' {
891                return Err(Invariant::CarriageReturn);
892            }
893            if is_bidi_char(c) {
894                return Err(Invariant::BidiControl(c));
895            }
896            if c == ISLAND_SLOT {
897                slots += 1;
898            }
899        }
900        if slots != self.islands.len() {
901            return Err(Invariant::IslandSlotMismatch {
902                slots,
903                islands: self.islands.len(),
904            });
905        }
906        let segments = self.segment_count();
907        if self.lines.len() != segments {
908            return Err(Invariant::LineCountMismatch {
909                lines: self.lines.len(),
910                segments,
911            });
912        }
913        if self.lines.first().is_some_and(|l| l.continues) {
914            return Err(Invariant::FirstLineContinues);
915        }
916        let len = self.len_usv();
917        let chars: Vec<char> = self.text.chars().collect();
918        // Prose anchor ids: unique, non-empty, caller-supplied opaque handles
919        // (`DOCUMENT_STORAGE.md` § Anchor-id identity). Uniqueness (the same
920        // invariant the island loop enforces below) is what `RemoveAnchor`
921        // presumes; scope is prose marks, cell anchors excluded by construction.
922        let mut seen_anchor_ids = std::collections::HashSet::new();
923        for m in &self.marks {
924            if m.start > m.end || m.end > len {
925                return Err(Invariant::MarkOutOfRange {
926                    start: m.start,
927                    end: m.end,
928                    len,
929                });
930            }
931            if m.start == m.end && m.kind.is_formatting() {
932                return Err(Invariant::ZeroWidthFormatting { at: m.start });
933            }
934            if m.kind.is_formatting() {
935                if chars.get(m.start) == Some(&'\n') {
936                    return Err(Invariant::MarkEdgeOnNewline { at: m.start });
937                }
938                if m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
939                    return Err(Invariant::MarkEdgeOnNewline { at: m.end - 1 });
940                }
941            }
942            match &m.kind {
943                MarkKind::Unknown { tag, attrs } => {
944                    if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
945                        return Err(Invariant::ReservedUnknownTag(tag.clone()));
946                    }
947                    check_json_depth(attrs, "mark attrs")?;
948                }
949                MarkKind::Anchor { id } => {
950                    if id.is_empty() || !seen_anchor_ids.insert(id.as_str()) {
951                        return Err(Invariant::AnchorIdCollision { id: id.clone() });
952                    }
953                }
954                _ => {}
955            }
956        }
957        // One pass over the lines against their own text segment. `lines.len()`
958        // already equals the segment count, so the zip is total.
959        for (i, (line, seg)) in self.lines.iter().zip(self.text.split('\n')).enumerate() {
960            match &line.kind {
961                LineKind::Heading { level } if !(1..=6).contains(level) => {
962                    return Err(Invariant::BadHeadingLevel(*level));
963                }
964                // An unknown role may not reuse a built-in `kind` name: it would
965                // serialize as the built-in and parse back as one, dropping its
966                // attrs, the mark-side rule, one axis over.
967                LineKind::Unknown { tag, attrs } => {
968                    if Self::RESERVED_LINE_KINDS.contains(&tag.as_str()) {
969                        return Err(Invariant::ReservedUnknownLineKind(tag.clone()));
970                    }
971                    check_json_depth(attrs, "line attrs")?;
972                }
973                _ => {}
974            }
975            for c in &line.containers {
976                if let Container::Unknown { tag, attrs } = c {
977                    if Self::RESERVED_CONTAINERS.contains(&tag.as_str()) {
978                        return Err(Invariant::ReservedUnknownContainer(tag.clone()));
979                    }
980                    check_json_depth(attrs, "container attrs")?;
981                }
982            }
983            if let Some(mismatch) = line_kind_mismatch(&line.kind, seg) {
984                return Err(Invariant::LineKindMismatch { line: i, mismatch });
985            }
986            if line.containers.len() > crate::MAX_NESTING_DEPTH {
987                return Err(Invariant::NestingTooDeep {
988                    line: i,
989                    depth: line.containers.len(),
990                    max: crate::MAX_NESTING_DEPTH,
991                });
992            }
993        }
994        // Table-cell marks: the prose range/zero-width/reserved-tag rules again,
995        // but each mark is bounded by its own cell's text length (in USV). Cells
996        // hold no `\n`, so the edge-on-newline rule does not apply.
997        let mut seen_ids = std::collections::HashSet::with_capacity(self.islands.len());
998        for island in &self.islands {
999            // Ids are deterministic, session-stable identities (hash input), so
1000            // two islands may not share one. Uniqueness (not `id == isl-{i}`)
1001            // is the invariant: edits keep an island's id across renumbers.
1002            if !seen_ids.insert(island.id.as_str()) {
1003                return Err(Invariant::IslandIdCollision {
1004                    id: island.id.clone(),
1005                });
1006            }
1007            // Payload depth before any pass that walks `props`. A cell's own
1008            // `attrs` is a subtree of `props`, so one check bounds the cell marks
1009            // the loop below reads as well.
1010            check_json_depth(&island.props, "island props")?;
1011            // Structural shape (table column/row/aligns consistency, `\n`-free
1012            // cells) before the per-cell mark ranges: a ragged island is
1013            // ill-formed regardless of its marks.
1014            if let Some(e) = crate::island::island_shape_error(island) {
1015                return Err(e);
1016            }
1017            for (text, marks) in crate::island::island_cell_marks(island) {
1018                let clen = text.chars().count();
1019                for m in &marks {
1020                    if m.start > m.end || m.end > clen {
1021                        return Err(Invariant::MarkOutOfRange {
1022                            start: m.start,
1023                            end: m.end,
1024                            len: clen,
1025                        });
1026                    }
1027                    if m.start == m.end && m.kind.is_formatting() {
1028                        return Err(Invariant::ZeroWidthFormatting { at: m.start });
1029                    }
1030                    if let MarkKind::Unknown { tag, .. } = &m.kind {
1031                        if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
1032                            return Err(Invariant::ReservedUnknownTag(tag.clone()));
1033                        }
1034                    }
1035                }
1036            }
1037        }
1038        Ok(())
1039    }
1040}
1041
1042/// Apply the three Spike-A rules and the canonical sort to a flat mark list.
1043///
1044/// 1. Same-kind formatting marks union when adjacent *or* overlapping.
1045/// 2. Different-kind marks overlap freely (never split into runs).
1046/// 3. Identity (and unknown) marks never merge.
1047///
1048/// Zero-width formatting marks are dropped (no-ops); zero-width anchors survive.
1049pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
1050    use std::collections::BTreeMap;
1051
1052    // Partition: formatting marks group by (ord, attrs_key) for union; identity
1053    // and unknown pass through untouched (but zero-width formatting is dropped).
1054    let mut groups: BTreeMap<(u8, String), Vec<(Usv, Usv)>> = BTreeMap::new();
1055    let mut kind_of: BTreeMap<(u8, String), MarkKind> = BTreeMap::new();
1056    let mut passthrough: Vec<Mark> = Vec::new();
1057
1058    for m in marks {
1059        if m.kind.is_formatting() {
1060            if m.start >= m.end {
1061                continue; // drop zero-width / inverted formatting
1062            }
1063            let key = (m.kind.ord(), m.kind.attrs_key());
1064            kind_of.entry(key.clone()).or_insert_with(|| m.kind.clone());
1065            groups.entry(key).or_default().push((m.start, m.end));
1066        } else {
1067            passthrough.push(m);
1068        }
1069    }
1070
1071    let mut out: Vec<Mark> = Vec::new();
1072    for (key, mut ranges) in groups {
1073        ranges.sort_unstable();
1074        let kind = kind_of.remove(&key).expect("kind recorded with group");
1075        let mut cur = ranges[0];
1076        for &(s, e) in &ranges[1..] {
1077            if s <= cur.1 {
1078                // adjacent (s == cur.1) or overlapping: union
1079                cur.1 = cur.1.max(e);
1080            } else {
1081                out.push(Mark {
1082                    start: cur.0,
1083                    end: cur.1,
1084                    kind: kind.clone(),
1085                });
1086                cur = (s, e);
1087            }
1088        }
1089        out.push(Mark {
1090            start: cur.0,
1091            end: cur.1,
1092            kind,
1093        });
1094    }
1095    out.extend(passthrough);
1096
1097    // Canonical sort: (start, end, kind-ord, attrs). Key cached per mark so
1098    // `attrs_key`'s allocation runs once each, not once per comparison.
1099    out.sort_by_cached_key(|m| (m.start, m.end, m.kind.ord(), m.kind.attrs_key()));
1100    // Drop byte-identical duplicates. Identity/unknown handles never *merge*
1101    // (Spike-A rule 3), but two marks equal in range, kind, and attrs are the
1102    // same handle recorded twice: redundant bytes, not two handles. The sort
1103    // above makes any such pair adjacent, so `dedup` (structural `PartialEq`,
1104    // order-independent for `Unknown` attrs under `preserve_order`) removes it.
1105    out.dedup();
1106    out
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    fn f(start: Usv, end: Usv, kind: MarkKind) -> Mark {
1114        Mark { start, end, kind }
1115    }
1116
1117
1118    #[test]
1119    fn is_blank_tracks_whitespace_and_islands() {
1120        assert!(Content::empty().is_blank());
1121        let mut ws = Content::empty();
1122        ws.text = "  \n\t ".to_string();
1123        ws.lines = vec![
1124            Line {
1125                kind: LineKind::Para,
1126                containers: Vec::new(),
1127                continues: false,
1128            },
1129            Line {
1130                kind: LineKind::Para,
1131                containers: Vec::new(),
1132                continues: false,
1133            },
1134        ];
1135        assert!(ws.is_blank(), "whitespace-only text is blank");
1136
1137        let mut has_text = Content::empty();
1138        has_text.text = "x".to_string();
1139        assert!(!has_text.is_blank());
1140
1141        // An island slot is not whitespace, so an island-bearing content is
1142        // never blank even with no other text.
1143        let mut island_only = Content::empty();
1144        island_only.text = ISLAND_SLOT.to_string();
1145        assert!(!island_only.is_blank());
1146    }
1147
1148    /// A single-line content over `text` tagged `kind`: the shape a `SetKind`
1149    /// or a splice can leave behind.
1150    fn tagged(text: &str, kind: LineKind) -> Content {
1151        Content {
1152            text: text.to_string(),
1153            lines: vec![Line {
1154                kind,
1155                containers: Vec::new(),
1156                continues: false,
1157            }],
1158            marks: Vec::new(),
1159            islands: Vec::new(),
1160        }
1161    }
1162
1163    /// A line kind that contradicts the line's text is refused.
1164    /// Export trusts the kind and never re-reads the segment, so `Island` over
1165    /// prose projects to the island alone and `Rule` over prose to `---`: the
1166    /// text silently gone.
1167    #[test]
1168    fn line_kind_must_agree_with_line_text() {
1169        assert_eq!(
1170            tagged("hello world", LineKind::Island).validate(),
1171            Err(Invariant::LineKindMismatch {
1172                line: 0,
1173                mismatch: LineKindMismatch::IslandNotOneSlot
1174            })
1175        );
1176        assert_eq!(
1177            tagged("", LineKind::Island).validate(),
1178            Err(Invariant::LineKindMismatch {
1179                line: 0,
1180                mismatch: LineKindMismatch::IslandNotOneSlot
1181            })
1182        );
1183        assert_eq!(
1184            tagged("important text", LineKind::Rule).validate(),
1185            Err(Invariant::LineKindMismatch {
1186                line: 0,
1187                mismatch: LineKindMismatch::RuleNotEmpty
1188            })
1189        );
1190        // `Para`/`Heading` carry slots (an inline image is a slot in prose)
1191        // so only a fence, whose text is emitted verbatim, refuses one.
1192        let mut code = tagged(&format!("a{ISLAND_SLOT}b"), LineKind::Code { lang: None });
1193        code.islands = vec![Island {
1194            id: "isl-0".into(),
1195            island_type: "image".into(),
1196            props: serde_json::json!({"alt": "x", "url": "y.png"}),
1197            loss: Loss::LOSSLESS,
1198        }];
1199        assert_eq!(
1200            code.validate(),
1201            Err(Invariant::LineKindMismatch {
1202                line: 0,
1203                mismatch: LineKindMismatch::CodeHasSlot
1204            })
1205        );
1206        let mut para = code.clone();
1207        para.lines[0].kind = LineKind::Para;
1208        assert_eq!(para.validate(), Ok(()));
1209        let mut heading = code.clone();
1210        heading.lines[0].kind = LineKind::Heading { level: 1 };
1211        assert_eq!(heading.validate(), Ok(()));
1212        // The kinds that name their content, holding it.
1213        assert_eq!(tagged("", LineKind::Rule).validate(), Ok(()));
1214    }
1215
1216    /// A splice writes text, never kinds, so it can strand an
1217    /// `Island` line over prose. `normalize` demotes the stranded kind to `Para`
1218    /// rather than let export drop the text: the repair-side twin of the
1219    /// invariant, and what keeps every op path's terminal normalize total.
1220    #[test]
1221    fn normalize_demotes_a_stranded_line_kind() {
1222        let mut rt = tagged("typed into a table line", LineKind::Island);
1223        rt.normalize();
1224        assert_eq!(rt.lines[0].kind, LineKind::Para);
1225        assert_eq!(rt.validate(), Ok(()));
1226        let mut rt = tagged("text on a rule line", LineKind::Rule);
1227        rt.normalize();
1228        assert_eq!(rt.lines[0].kind, LineKind::Para);
1229        // A well-formed island line is left alone.
1230        let mut rt = tagged(&ISLAND_SLOT.to_string(), LineKind::Island);
1231        rt.islands = vec![Island {
1232            id: "isl-0".into(),
1233            island_type: "image".into(),
1234            props: serde_json::json!({"alt": "x", "url": "y.png"}),
1235            loss: Loss::LOSSLESS,
1236        }];
1237        rt.normalize();
1238        assert_eq!(rt.lines[0].kind, LineKind::Island);
1239        assert_eq!(rt.validate(), Ok(()));
1240    }
1241
1242    /// Container nesting is capped at the same depth import
1243    /// enforces, so a content that never went through import cannot reach the
1244    /// emitters (which recurse one frame per container) with an unbounded path.
1245    #[test]
1246    fn container_nesting_is_capped() {
1247        let mut rt = tagged("hi", LineKind::Para);
1248        rt.lines[0].containers = vec![Container::Quote; crate::MAX_NESTING_DEPTH];
1249        assert_eq!(rt.validate(), Ok(()));
1250        rt.lines[0].containers.push(Container::Quote);
1251        assert_eq!(
1252            rt.validate(),
1253            Err(Invariant::NestingTooDeep {
1254                line: 0,
1255                depth: crate::MAX_NESTING_DEPTH + 1,
1256                max: crate::MAX_NESTING_DEPTH,
1257            })
1258        );
1259    }
1260
1261    /// [`container_nesting_is_capped`] on the payload axis. The
1262    /// decoders refuse an over-deep bag at the wire; this is the same cap for
1263    /// content that never went through one, since the recursive consumers spend
1264    /// a frame per level whatever built the tree.
1265    #[test]
1266    fn json_payload_depth_is_capped() {
1267        let nested = |depth: usize| {
1268            let mut v = JsonValue::Null;
1269            for _ in 0..depth {
1270                v = JsonValue::Array(vec![v]);
1271            }
1272            v
1273        };
1274        let too_deep = |what: &'static str| {
1275            Err(Invariant::JsonTooDeep {
1276                what,
1277                max: crate::MAX_JSON_DEPTH,
1278            })
1279        };
1280
1281        let mut rt = tagged("hi", LineKind::Para);
1282        rt.lines[0].kind = LineKind::Unknown {
1283            tag: "callout".into(),
1284            attrs: nested(crate::MAX_JSON_DEPTH),
1285        };
1286        assert_eq!(rt.validate(), Ok(()));
1287        rt.lines[0].kind = LineKind::Unknown {
1288            tag: "callout".into(),
1289            attrs: nested(crate::MAX_JSON_DEPTH + 1),
1290        };
1291        assert_eq!(rt.validate(), too_deep("line attrs"));
1292
1293        let mut rt = tagged("hi", LineKind::Para);
1294        rt.lines[0].containers = vec![Container::Unknown {
1295            tag: "indent".into(),
1296            attrs: nested(crate::MAX_JSON_DEPTH + 1),
1297        }];
1298        assert_eq!(rt.validate(), too_deep("container attrs"));
1299
1300        let mut rt = tagged("hi", LineKind::Para);
1301        rt.marks = vec![Mark {
1302            start: 0,
1303            end: 2,
1304            kind: MarkKind::Unknown {
1305                tag: "sparkle".into(),
1306                attrs: nested(crate::MAX_JSON_DEPTH + 1),
1307            },
1308        }];
1309        assert_eq!(rt.validate(), too_deep("mark attrs"));
1310
1311        let mut rt = tagged("\u{fffc}", LineKind::Island);
1312        rt.islands = vec![Island {
1313            id: "i1".into(),
1314            island_type: "widget".into(),
1315            props: nested(crate::MAX_JSON_DEPTH + 1),
1316            loss: Loss::LOSSLESS,
1317        }];
1318        assert_eq!(rt.validate(), too_deep("island props"));
1319    }
1320
1321    #[test]
1322    fn same_kind_adjacent_unions() {
1323        // [0,3) strong + [3,6) strong -> [0,6) strong (rule 1, adjacency).
1324        let got = normalize_marks(vec![f(3, 6, MarkKind::Strong), f(0, 3, MarkKind::Strong)]);
1325        assert_eq!(got, vec![f(0, 6, MarkKind::Strong)]);
1326    }
1327
1328    #[test]
1329    fn same_kind_overlapping_unions() {
1330        let got = normalize_marks(vec![f(0, 4, MarkKind::Emph), f(2, 7, MarkKind::Emph)]);
1331        assert_eq!(got, vec![f(0, 7, MarkKind::Emph)]);
1332    }
1333
1334    #[test]
1335    fn different_kinds_overlap_freely() {
1336        // Strong and emph over overlapping ranges stay two marks (rule 2).
1337        let got = normalize_marks(vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]);
1338        assert_eq!(
1339            got,
1340            vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]
1341        );
1342    }
1343
1344    #[test]
1345    fn links_union_only_at_same_url() {
1346        let a = MarkKind::Link { url: "a".into() };
1347        let b = MarkKind::Link { url: "b".into() };
1348        // Same url adjacent -> union; different url -> distinct.
1349        let got = normalize_marks(vec![
1350            f(0, 2, a.clone()),
1351            f(2, 4, a.clone()),
1352            f(4, 6, b.clone()),
1353        ]);
1354        assert_eq!(got, vec![f(0, 4, a), f(4, 6, b)]);
1355    }
1356
1357    #[test]
1358    fn identity_never_merges() {
1359        // Two anchors over the same range are two distinct things (rule 3).
1360        let a = MarkKind::Anchor { id: "c1".into() };
1361        let b = MarkKind::Anchor { id: "c2".into() };
1362        let got = normalize_marks(vec![f(3, 3, a.clone()), f(3, 3, b.clone())]);
1363        assert_eq!(got.len(), 2);
1364        assert!(got.contains(&f(3, 3, a)));
1365        assert!(got.contains(&f(3, 3, b)));
1366    }
1367
1368    #[test]
1369    fn zero_width_formatting_dropped_zero_width_anchor_kept() {
1370        let got = normalize_marks(vec![
1371            f(2, 2, MarkKind::Strong),
1372            f(2, 2, MarkKind::Anchor { id: "x".into() }),
1373        ]);
1374        assert_eq!(got, vec![f(2, 2, MarkKind::Anchor { id: "x".into() })]);
1375    }
1376
1377    #[test]
1378    fn empty_is_valid() {
1379        assert_eq!(Content::empty().validate(), Ok(()));
1380    }
1381
1382    #[test]
1383    fn is_inline_accepts_empty_and_single_para() {
1384        assert!(Content::empty().is_inline());
1385        assert!(crate::import::from_markdown("just one line")
1386            .unwrap()
1387            .is_inline());
1388        assert!(crate::import::from_markdown("a *bold* run")
1389            .unwrap()
1390            .is_inline());
1391    }
1392
1393    #[test]
1394    fn is_inline_rejects_blocks_containers_and_islands() {
1395        // Two paragraphs → two Para lines.
1396        assert!(!crate::import::from_markdown("one\n\ntwo")
1397            .unwrap()
1398            .is_inline());
1399        // A heading is a non-Para line kind.
1400        assert!(!crate::import::from_markdown("# heading")
1401            .unwrap()
1402            .is_inline());
1403        // A list item sits in a container.
1404        assert!(!crate::import::from_markdown("- item").unwrap().is_inline());
1405    }
1406
1407    #[test]
1408    fn validate_catches_slot_mismatch() {
1409        let mut rt = Content::empty();
1410        rt.text = "\u{FFFC}".into();
1411        rt.lines = vec![Line {
1412            kind: LineKind::Island,
1413            containers: vec![],
1414            continues: false,
1415        }];
1416        assert_eq!(
1417            rt.validate(),
1418            Err(Invariant::IslandSlotMismatch {
1419                slots: 1,
1420                islands: 0
1421            })
1422        );
1423    }
1424
1425    #[test]
1426    fn validate_catches_line_count() {
1427        let mut rt = Content::empty();
1428        rt.text = "a\nb".into(); // 2 segments, but 1 line
1429        assert_eq!(
1430            rt.validate(),
1431            Err(Invariant::LineCountMismatch {
1432                lines: 1,
1433                segments: 2
1434            })
1435        );
1436    }
1437
1438    #[test]
1439    fn normalize_is_idempotent() {
1440        let mut rt = Content::empty();
1441        rt.text = "hello world".into();
1442        rt.marks = vec![
1443            f(6, 11, MarkKind::Strong),
1444            f(0, 5, MarkKind::Strong),
1445            f(0, 5, MarkKind::Emph),
1446        ];
1447        rt.normalize();
1448        let once = rt.marks.clone();
1449        rt.normalize();
1450        assert_eq!(rt.marks, once);
1451        assert_eq!(rt.validate(), Ok(()));
1452    }
1453
1454    /// A table cell built with un-normalized marks (reversed order, an adjacent
1455    /// same-kind pair, a zero-width formatting mark) canonicalizes to the same
1456    /// marks whatever the input order: the live-model determinism invariant.
1457    #[test]
1458    fn table_cell_marks_normalize_and_are_idempotent() {
1459        fn table(cell_marks: serde_json::Value) -> Content {
1460            let mut rt = Content::empty();
1461            rt.text = ISLAND_SLOT.to_string();
1462            rt.lines = vec![Line {
1463                kind: LineKind::Island,
1464                containers: vec![],
1465                continues: false,
1466            }];
1467            rt.islands = vec![Island {
1468                id: "i".into(),
1469                island_type: "table".into(),
1470                props: serde_json::json!({
1471                    "aligns": ["none"],
1472                    "header": [{"text": "abcd", "marks": cell_marks}],
1473                    "rows": [],
1474                }),
1475                loss: Loss::LOSSLESS,
1476            }];
1477            rt
1478        }
1479        // Reversed order + adjacent same-kind pair (0..2)+(2..4) → unioned 0..4;
1480        // a zero-width strong at 1 → dropped.
1481        let mut a = table(serde_json::json!([
1482            {"start": 2, "end": 4, "type": "strong"},
1483            {"start": 1, "end": 1, "type": "strong"},
1484            {"start": 0, "end": 2, "type": "strong"}
1485        ]));
1486        a.normalize();
1487        assert_eq!(a.validate(), Ok(()));
1488        let cell = &a.islands[0].props["header"][0];
1489        assert_eq!(cell["marks"].as_array().unwrap().len(), 1);
1490        assert_eq!(cell["marks"][0]["start"], 0);
1491        assert_eq!(cell["marks"][0]["end"], 4);
1492        // Same content, different input order → identical canonical bytes.
1493        let mut b = table(serde_json::json!([
1494            {"start": 0, "end": 2, "type": "strong"},
1495            {"start": 2, "end": 4, "type": "strong"}
1496        ]));
1497        b.normalize();
1498        assert_eq!(a.to_canonical_json(), b.to_canonical_json());
1499        // Idempotent.
1500        let once = a.to_canonical_json();
1501        a.normalize();
1502        assert_eq!(a.to_canonical_json(), once);
1503    }
1504
1505    /// A cell is canonicalized in place, so a key this build does
1506    /// not recognize survives. The rule has no slack: `normalize` runs on
1507    /// decode, on every op apply, and on every serialize, so a cell rebuilt
1508    /// whole drops the key on first contact and every contact after.
1509    #[test]
1510    fn unrecognized_cell_key_survives_normalize() {
1511        // Two columns, one body cell: `pad_row` mints the second, so the same
1512        // pass exercises both a carried cell and a synthesized one.
1513        let mut rt = table_rt(serde_json::json!({
1514            "aligns": ["none", "none"],
1515            "header": [{"text": "h", "marks": [], "colspan": 2}, cell("h2")],
1516            "rows": [[cell("a")]],
1517        }));
1518        rt.normalize();
1519        assert_eq!(rt.islands[0].props["header"][0]["colspan"], 2);
1520        // A minted cell has nothing to carry.
1521        assert!(rt.islands[0].props["rows"][0][1].get("colspan").is_none());
1522        // Hash input like any other key.
1523        assert!(rt.to_canonical_json().contains(r#""colspan":2"#));
1524    }
1525
1526    /// `validate` bounds a cell mark by its own cell's text length (in USV).
1527    #[test]
1528    fn validate_catches_cell_mark_out_of_range() {
1529        let mut rt = Content::empty();
1530        rt.text = ISLAND_SLOT.to_string();
1531        rt.lines = vec![Line {
1532            kind: LineKind::Island,
1533            containers: vec![],
1534            continues: false,
1535        }];
1536        rt.islands = vec![Island {
1537            id: "i".into(),
1538            island_type: "table".into(),
1539            props: serde_json::json!({
1540                "aligns": ["none"],
1541                // "ab" is 2 USV; a mark ending at 5 runs past the cell.
1542                "header": [{"text": "ab", "marks": [{"start": 0, "end": 5, "type": "strong"}]}],
1543                "rows": [],
1544            }),
1545            loss: Loss::LOSSLESS,
1546        }];
1547        assert_eq!(
1548            rt.validate(),
1549            Err(Invariant::MarkOutOfRange {
1550                start: 0,
1551                end: 5,
1552                len: 2
1553            })
1554        );
1555    }
1556
1557    /// A `Content` holding a single table island with the given props: the
1558    /// shared fixture for the table-shape invariant tests.
1559    fn table_rt(props: serde_json::Value) -> Content {
1560        let mut rt = Content::empty();
1561        rt.text = ISLAND_SLOT.to_string();
1562        rt.lines = vec![Line {
1563            kind: LineKind::Island,
1564            containers: vec![],
1565            continues: false,
1566        }];
1567        rt.islands = vec![Island {
1568            id: "i".into(),
1569            island_type: "table".into(),
1570            props,
1571            loss: Loss::LOSSLESS,
1572        }];
1573        rt
1574    }
1575
1576    fn cell(t: &str) -> serde_json::Value {
1577        serde_json::json!({ "text": t, "marks": [] })
1578    }
1579
1580    /// `validate` rejects a ragged body row, an `aligns`/column mismatch, and a
1581    /// cell carrying a `\n`: the three table-shape invariants.
1582    #[test]
1583    fn validate_catches_table_shape() {
1584        // Ragged row: header has 2 columns, the row has 3.
1585        let rt = table_rt(serde_json::json!({
1586            "aligns": ["none", "none"],
1587            "header": [cell("a"), cell("b")],
1588            "rows": [[cell("1"), cell("2"), cell("3")]],
1589        }));
1590        assert_eq!(
1591            rt.validate(),
1592            Err(Invariant::TableRaggedRow {
1593                row: 0,
1594                width: 3,
1595                cols: 2
1596            })
1597        );
1598
1599        // aligns length differs from the column count.
1600        let rt = table_rt(serde_json::json!({
1601            "aligns": ["none"],
1602            "header": [cell("a"), cell("b")],
1603            "rows": [],
1604        }));
1605        assert_eq!(
1606            rt.validate(),
1607            Err(Invariant::TableAlignsMismatch { aligns: 1, cols: 2 })
1608        );
1609
1610        // A `\n` in a cell (flat header-then-rows index 1 = the second header cell).
1611        let rt = table_rt(serde_json::json!({
1612            "aligns": ["none", "none"],
1613            "header": [cell("a"), cell("b\nc")],
1614            "rows": [],
1615        }));
1616        assert_eq!(rt.validate(), Err(Invariant::TableCellNewline { cell: 1 }));
1617    }
1618
1619    /// `normalize` repairs every table-shape violation: pads the header and
1620    /// short rows to the widest column count, syncs `aligns`, and rewrites a
1621    /// cell `\n` to a space, so the result validates and is idempotent. This is
1622    /// also the one-column-count unification: the widest row (3) drives the
1623    /// header width, so the markdown (header-derived) and Typst (widest-row)
1624    /// projections agree.
1625    #[test]
1626    fn normalize_repairs_table_shape() {
1627        let mut rt = table_rt(serde_json::json!({
1628            "aligns": ["none"],
1629            "header": [cell("h")],
1630            "rows": [
1631                [cell("a"), cell("b"), cell("c")],
1632                [cell("d\ne")],
1633            ],
1634        }));
1635        rt.normalize();
1636        assert_eq!(rt.validate(), Ok(()));
1637
1638        let props = &rt.islands[0].props;
1639        assert_eq!(props["header"].as_array().unwrap().len(), 3);
1640        assert_eq!(props["aligns"].as_array().unwrap().len(), 3);
1641        for row in props["rows"].as_array().unwrap() {
1642            assert_eq!(row.as_array().unwrap().len(), 3);
1643        }
1644        // Padded aligns default to "none"; the padded cells are empty.
1645        assert_eq!(props["aligns"][2], serde_json::json!("none"));
1646        assert_eq!(props["header"][1]["text"], serde_json::json!(""));
1647        // The `\n` in "d\ne" became a space, preserving char count.
1648        assert_eq!(props["rows"][1][0]["text"], serde_json::json!("d e"));
1649
1650        // Idempotent on canonical bytes.
1651        let once = rt.to_canonical_json();
1652        rt.normalize();
1653        assert_eq!(rt.to_canonical_json(), once);
1654    }
1655
1656    /// An empty table (no header, no rows) is trivially well-formed: every width
1657    /// is zero, so no shape invariant fires and `normalize` leaves it alone.
1658    #[test]
1659    fn empty_table_is_valid() {
1660        let mut rt = table_rt(serde_json::json!({
1661            "aligns": [],
1662            "header": [],
1663            "rows": [],
1664        }));
1665        assert_eq!(rt.validate(), Ok(()));
1666        rt.normalize();
1667        assert_eq!(rt.validate(), Ok(()));
1668    }
1669
1670    /// A non-array `header` carries no cells: `validate` rejects it and
1671    /// `normalize` repairs it to an empty array (a zero-column table that then
1672    /// validates).
1673    #[test]
1674    fn non_array_table_header_is_rejected_then_repaired() {
1675        let mut rt = table_rt(serde_json::json!({
1676            "header": "oops",
1677            "aligns": [],
1678            "rows": [],
1679        }));
1680        assert_eq!(rt.validate(), Err(Invariant::TableHeaderNotArray));
1681        rt.normalize();
1682        assert_eq!(rt.validate(), Ok(()));
1683        assert_eq!(rt.islands[0].props["header"], serde_json::json!([]));
1684    }
1685
1686    /// Two islands sharing an `id` violate the minted-identity invariant.
1687    /// Import mints ids by index so never collides; a hand-built content can.
1688    #[test]
1689    fn duplicate_island_id_is_rejected() {
1690        let mut rt = Content::empty();
1691        rt.text = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}");
1692        rt.lines = vec![
1693            Line {
1694                kind: LineKind::Island,
1695                containers: vec![],
1696                continues: false,
1697            },
1698            Line {
1699                kind: LineKind::Island,
1700                containers: vec![],
1701                continues: false,
1702            },
1703        ];
1704        let table = |id: &str| Island {
1705            id: id.into(),
1706            island_type: "table".into(),
1707            props: serde_json::json!({ "header": [cell("h")], "aligns": ["none"], "rows": [] }),
1708            loss: Loss::LOSSLESS,
1709        };
1710        rt.islands = vec![table("dup"), table("dup")];
1711        assert_eq!(
1712            rt.validate(),
1713            Err(Invariant::IslandIdCollision { id: "dup".into() })
1714        );
1715        // Distinct ids validate.
1716        rt.islands = vec![table("a"), table("b")];
1717        assert_eq!(rt.validate(), Ok(()));
1718    }
1719
1720    /// Two prose anchors sharing an `id` at different ranges violate the
1721    /// anchor-id uniqueness invariant, as does the empty id. (Byte-identical
1722    /// anchors `normalize` already dedupes; this is the surviving collision.)
1723    #[test]
1724    fn duplicate_or_empty_anchor_id_is_rejected() {
1725        let mut rt = Content::empty();
1726        rt.text = "abcd".into();
1727        let anchor = |start, end, id: &str| Mark {
1728            start,
1729            end,
1730            kind: MarkKind::Anchor { id: id.into() },
1731        };
1732        // Same id at two ranges: `RemoveAnchor` can't disambiguate them.
1733        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "x")];
1734        assert_eq!(
1735            rt.validate(),
1736            Err(Invariant::AnchorIdCollision { id: "x".into() })
1737        );
1738        // Distinct ids over distinct ranges validate.
1739        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "y")];
1740        assert_eq!(rt.validate(), Ok(()));
1741        // The empty id is a degenerate handle.
1742        rt.marks = vec![anchor(0, 2, "")];
1743        assert_eq!(
1744            rt.validate(),
1745            Err(Invariant::AnchorIdCollision { id: String::new() })
1746        );
1747    }
1748
1749    /// `normalize` drops a byte-identical duplicate identity mark (same range,
1750    /// same id): the same handle recorded twice is redundant, not two handles.
1751    /// Distinct-id anchors over the same range are kept.
1752    #[test]
1753    fn normalize_dedupes_identical_identity_marks() {
1754        let mut rt = Content::empty();
1755        rt.text = "abcd".into();
1756        let anchor = |id: &str| Mark {
1757            start: 0,
1758            end: 4,
1759            kind: MarkKind::Anchor { id: id.into() },
1760        };
1761        rt.marks = vec![anchor("x"), anchor("x")];
1762        rt.normalize();
1763        assert_eq!(rt.marks, vec![anchor("x")]);
1764        // Different ids over the same range are distinct handles: both survive.
1765        rt.marks = vec![anchor("x"), anchor("y")];
1766        rt.normalize();
1767        assert_eq!(rt.marks.len(), 2);
1768    }
1769}