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