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