Skip to main content

quillmark_content/
serial.rs

1//! Canonical JSON serialization: the freeze.
2//!
3//! Byte-deterministic within this schema: equal [`Content`] values (by
4//! `PartialEq` after [`Content::normalize`]) serialize to byte-equal JSON,
5//! insensitive to the order marks/islands were discovered in. Three order
6//! sources are closed here and in `normalize`: mark order (canonical sort),
7//! island order (slot position), and object-key order inside island `props` /
8//! unknown-mark `attrs` (recursively sorted).
9//!
10//! Two fixed points, and they are not the same promise. **Bytes**:
11//! `to_canonical_json(from_canonical_json(b)) == b` for canonical `b`, which is
12//! what a consumer hashing stored documents spends (`DOCUMENT_STORAGE.md` §
13//! Byte-stability). **Values**: `from_canonical_json(to_canonical_json(rt)) == rt`
14//! for a normalized `rt`, which is what an in-process producer spends, and which
15//! holds only while every discriminator's encoding is injective. An axis can keep
16//! the first and lose the second: a value that encodes to some *other* value's
17//! bytes moves nothing on disk and still fails to survive its own round trip.
18//!
19//! The seam encoding (Option A) and the storage encoding are the *same*
20//! canonical form: one serializer, not two to keep aligned.
21
22use crate::model::{
23    sort_keys_owned, Container, Invariant, Island, Line, LineKind, Loss, Mark,
24    MarkKind, Content, Usv,
25};
26use serde_json::{Map, Value};
27use std::borrow::Cow;
28
29/// Why canonical-JSON parsing failed. Structural only: a well-formed producer
30/// (this crate's serializer, the seam, storage) never trips these.
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum ParseError {
34    /// Top-level JSON was not an object, or a required key was missing/mistyped.
35    Shape(&'static str),
36    /// The JSON itself did not parse.
37    Json(String),
38    /// The value parsed but violates a content invariant.
39    Invalid(crate::model::Invariant),
40}
41
42impl std::fmt::Display for ParseError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            ParseError::Shape(s) => write!(f, "content json shape: {s}"),
46            ParseError::Json(s) => write!(f, "content json parse: {s}"),
47            ParseError::Invalid(inv) => write!(f, "content invariant: {inv:?}"),
48        }
49    }
50}
51impl std::error::Error for ParseError {}
52
53impl Content {
54    /// Serialize to canonical JSON bytes. Normalizes a copy first, so the output
55    /// is canonical regardless of the caller's mark/island order. Every object
56    /// key is sorted recursively so the bytes do **not** depend on
57    /// `serde_json`'s `preserve_order` feature being enabled in the consumer's
58    /// crate graph: the canonical form is feature-independent.
59    pub fn to_canonical_json(&self) -> String {
60        to_canonical_value(self).to_string()
61    }
62
63    /// Parse canonical JSON, normalize (idempotent), and validate. Returns
64    /// [`ParseError::Invalid`] for a content that violates its invariants, so
65    /// storage cannot silently round-trip a malformed value.
66    /// `from_canonical_json(to_canonical_json(x))` round-trips to a canonical
67    /// value and re-serializes to identical bytes.
68    pub fn from_canonical_json(s: &str) -> Result<Content, ParseError> {
69        let v: Value = serde_json::from_str(s).map_err(|e| ParseError::Json(e.to_string()))?;
70        from_canonical_value(&v)
71    }
72
73    fn to_value(&self) -> Value {
74        let mut root = Map::new();
75        root.insert("text".into(), Value::String(self.text.clone()));
76        root.insert(
77            "lines".into(),
78            Value::Array(self.lines.iter().map(line_to_value).collect()),
79        );
80        root.insert(
81            "marks".into(),
82            Value::Array(self.marks.iter().map(mark_to_value).collect()),
83        );
84        root.insert(
85            "islands".into(),
86            Value::Array(self.islands.iter().map(island_to_value).collect()),
87        );
88        Value::Object(root)
89    }
90
91    fn from_value(v: &Value) -> Result<Content, ParseError> {
92        let obj = v.as_object().ok_or(ParseError::Shape("root not object"))?;
93        let text = obj
94            .get("text")
95            .and_then(Value::as_str)
96            .ok_or(ParseError::Shape("text"))?
97            .to_string();
98        let lines = arr(obj, "lines")?
99            .iter()
100            .map(line_from_value)
101            .collect::<Result<_, _>>()?;
102        let marks = arr(obj, "marks")?
103            .iter()
104            .map(mark_from_value)
105            .collect::<Result<_, _>>()?;
106        let islands = arr(obj, "islands")?
107            .iter()
108            .map(island_from_value)
109            .collect::<Result<_, _>>()?;
110        Ok(Content {
111            text,
112            lines,
113            marks,
114            islands,
115        })
116    }
117}
118
119/// The canonical content form as a structural [`Value`]: the recursively
120/// key-sorted tree [`Content::to_canonical_json`] renders to bytes. A storage
121/// layer embeds this as a nested object (never an escaped string): serializing
122/// the returned value with `serde_json` is byte-identical to that JSON
123/// (`to_canonical_value(rt).to_string() == rt.to_canonical_json()`), independent
124/// of the consumer's `preserve_order` feature. Normalizes a copy first, so the
125/// value is canonical whatever the caller's mark/island order.
126pub fn to_canonical_value(rt: &Content) -> Value {
127    let mut rt = rt.clone();
128    rt.normalize();
129    sort_keys_owned(rt.to_value())
130}
131
132/// Parse the canonical content form from a structural [`Value`], normalize
133/// (idempotent), and validate: the [`Value`]-input counterpart to
134/// [`Content::from_canonical_json`]. Returns [`ParseError::Invalid`] for a
135/// content that violates its invariants, so a storage layer parsing the embedded
136/// object rejects a malformed value at load rather than round-tripping it.
137pub fn from_canonical_value(v: &Value) -> Result<Content, ParseError> {
138    let mut rt = Content::from_value(v)?;
139    rt.normalize();
140    rt.validate().map_err(ParseError::Invalid)?;
141    Ok(rt)
142}
143
144/// Read an opaque payload bag (`attrs`, `props`) off the wire, absent → `Null`.
145/// Every bag any decoder retains comes through here, the wire twin of the
146/// [`Invariant::JsonTooDeep`] check in
147/// [`Content::validate`](crate::model::Content::validate).
148///
149/// **Depth-checked before the clone.** `Value::clone` spends a frame per level
150/// like every other consumer ([`MAX_JSON_DEPTH`](crate::MAX_JSON_DEPTH)), so an
151/// over-deep bag has to be refused while it is still borrowed from the caller's
152/// `Value`: once it is owned the frames are already spent, and dropping it
153/// spends them again.
154fn bag_from_wire(
155    o: &Map<String, Value>,
156    key: &'static str,
157    what: &'static str,
158) -> Result<Value, ParseError> {
159    let Some(v) = o.get(key) else {
160        return Ok(Value::Null);
161    };
162    crate::model::check_json_depth(v, what).map_err(ParseError::Invalid)?;
163    Ok(v.clone())
164}
165
166/// Read a wire position as a [`Usv`] index. **Checked**, not `as usize`: the
167/// deployment target is wasm32, where the truncating cast turns `2^32 + 5` into
168/// an in-range `5`, a mark silently landing at the wrong position instead of a
169/// rejected document. Every position the decoder reads goes through here.
170pub(crate) fn usv_from(v: Option<&Value>, what: &'static str) -> Result<Usv, ParseError> {
171    let n = v.and_then(Value::as_u64).ok_or(ParseError::Shape(what))?;
172    Usv::try_from(n).map_err(|_| ParseError::Shape(what))
173}
174
175fn arr<'a>(obj: &'a Map<String, Value>, key: &'static str) -> Result<&'a Vec<Value>, ParseError> {
176    obj.get(key)
177        .and_then(Value::as_array)
178        .ok_or(ParseError::Shape(key))
179}
180
181/// `v` as a slice, empty when it is not an array. The lenient counterpart to
182/// [`arr`], for a reader that only inspects what is there: [`from_canonical_value`]
183/// owns the shape errors.
184fn as_slice(v: &Value) -> &[Value] {
185    v.as_array().map(Vec::as_slice).unwrap_or_default()
186}
187
188/// `v[key]` as a slice, empty when the key is absent or not an array.
189fn arr_or_empty<'a>(v: &'a Value, key: &str) -> &'a [Value] {
190    v.get(key).map(as_slice).unwrap_or_default()
191}
192
193/// Fold a legacy `attrs` bag into the object when `tag` names a **built-in**:
194/// the storage lane's promotion path. A blob written while `callout` was outside
195/// this build's vocabulary carries `{"kind":"callout","attrs":{…}}`; the release
196/// that promotes `callout` to a built-in reads named siblings that blob never had
197/// and would drop its payload unread. Folding the bag's entries in before the
198/// built-in arms run makes each promotion carry its own legacy form structurally,
199/// rather than as something the promoting author has to remember
200/// (`DOCUMENT_STORAGE.md` § Promoting a vocabulary member).
201///
202/// Three bounds. A named sibling wins over an `attrs` entry: the built-in
203/// encoding is canonical wherever both spellings are present. Only a reserved
204/// name folds, so an unknown's bag stays its opaque payload. The discriminator is
205/// read from the *original* object, so a bag holding a `kind`/`type`/`container`
206/// key cannot re-target the match.
207///
208/// The authored lane never arrives here with this shape: it rejects `attrs`
209/// beside a built-in up front.
210///
211/// Depth-checked like [`bag_from_wire`], and for the same reason one level up: the
212/// fold deep-clones the object it folds into, so an over-deep bag spends the
213/// frames here even though a built-in never retains it as a bag.
214fn fold_legacy_attrs<'a>(
215    o: &'a Map<String, Value>,
216    tag: &str,
217    reserved: &[&str],
218    what: &'static str,
219) -> Result<Cow<'a, Map<String, Value>>, ParseError> {
220    let Some(bag @ Value::Object(attrs)) = o.get("attrs") else {
221        return Ok(Cow::Borrowed(o));
222    };
223    if attrs.is_empty() || !reserved.contains(&tag) {
224        return Ok(Cow::Borrowed(o));
225    }
226    crate::model::check_json_depth(bag, what).map_err(ParseError::Invalid)?;
227    let mut folded = o.clone();
228    for (k, v) in attrs {
229        folded.entry(k.clone()).or_insert_with(|| v.clone());
230    }
231    Ok(Cow::Owned(folded))
232}
233
234// ---- Line ----
235
236/// Encode a [`LineKind`] into its canonical `kind` fields (`"para"`,
237/// `{"kind":"heading","level":n}`, …). Public so the mark/line **op** wire
238/// ([`crate::ops`]) reuses the exact discriminant a `ContentLine` carries,
239/// rather than forking the encoding.
240pub fn line_kind_to_value(kind: &LineKind) -> Value {
241    let mut m = Map::new();
242    match kind {
243        LineKind::Para => {
244            m.insert("kind".into(), "para".into());
245        }
246        LineKind::Heading { level } => {
247            m.insert("kind".into(), "heading".into());
248            m.insert("level".into(), Value::from(*level));
249        }
250        LineKind::Code { lang } => {
251            m.insert("kind".into(), "code".into());
252            if let Some(l) = lang {
253                m.insert("lang".into(), Value::String(l.clone()));
254            }
255        }
256        LineKind::Island => {
257            m.insert("kind".into(), "island".into());
258        }
259        LineKind::Rule => {
260            m.insert("kind".into(), "rule".into());
261        }
262        // Open set, the mark encoding one axis over: the tag *is* the
263        // discriminator and the payload rides one opaque `attrs` bag, so a
264        // reader that lacks the role still carries it whole.
265        LineKind::Unknown { tag, attrs } => {
266            m.insert("kind".into(), Value::String(tag.clone()));
267            m.insert("attrs".into(), attrs.clone());
268        }
269    }
270    Value::Object(m)
271}
272
273/// Decode a [`LineKind`] from an object carrying the canonical `kind` fields.
274/// The inverse of [`line_kind_to_value`]; the shared line-kind reader for the
275/// line decoder and the line-op wire.
276pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
277    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
278    // A missing/non-string `kind` is the one shape error here: the open set
279    // absorbs unknown *names*, not malformed objects.
280    let tag = o
281        .get("kind")
282        .and_then(Value::as_str)
283        .ok_or(ParseError::Shape("line kind"))?;
284    let o = fold_legacy_attrs(o, tag, Content::RESERVED_LINE_KINDS, "line attrs")?;
285    match tag {
286        "para" => Ok(LineKind::Para),
287        "heading" => {
288            let level = o
289                .get("level")
290                .and_then(Value::as_u64)
291                .ok_or(ParseError::Shape("heading level"))?;
292            if !(1..=6).contains(&level) {
293                return Err(ParseError::Shape("heading level"));
294            }
295            Ok(LineKind::Heading { level: level as u8 })
296        }
297        "code" => Ok(LineKind::Code {
298            lang: o.get("lang").and_then(Value::as_str).map(str::to_string),
299        }),
300        "island" => Ok(LineKind::Island),
301        "rule" => Ok(LineKind::Rule),
302        // Open set: any other name is a block role this build lacks, kept opaque
303        // and projected as `Para`, the *document* still opens when its
304        // vocabulary grows.
305        other => Ok(LineKind::Unknown {
306            tag: other.to_string(),
307            attrs: bag_from_wire(&o, "attrs", "line attrs")?,
308        }),
309    }
310}
311
312fn line_to_value(line: &Line) -> Value {
313    let Value::Object(mut m) = line_kind_to_value(&line.kind) else {
314        unreachable!("line_kind_to_value always returns an object")
315    };
316    m.insert(
317        "containers".into(),
318        Value::Array(line.containers.iter().map(container_to_value).collect()),
319    );
320    // Omitted when false (the common case): deterministic since presence is a
321    // pure function of the value.
322    if line.continues {
323        m.insert("continues".into(), Value::Bool(true));
324    }
325    Value::Object(m)
326}
327
328fn line_from_value(v: &Value) -> Result<Line, ParseError> {
329    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
330    let kind = line_kind_from_value(v)?;
331    let containers = o
332        .get("containers")
333        .and_then(Value::as_array)
334        .ok_or(ParseError::Shape("containers"))?
335        .iter()
336        .map(container_from_value)
337        .collect::<Result<_, _>>()?;
338    let continues = o.get("continues").and_then(Value::as_bool).unwrap_or(false);
339    Ok(Line {
340        kind,
341        containers,
342        continues,
343    })
344}
345
346/// Encode a [`Container`] into its canonical wire object. Public so the line-op
347/// wire ([`crate::ops`]) reuses the same container shape a `ContentLine`
348/// carries.
349pub fn container_to_value(c: &Container) -> Value {
350    let mut m = Map::new();
351    match c {
352        Container::ListItem {
353            ordered,
354            start,
355            ordinal,
356        } => {
357            m.insert("container".into(), "list_item".into());
358            m.insert("ordered".into(), Value::Bool(*ordered));
359            m.insert("start".into(), Value::from(*start));
360            m.insert("ordinal".into(), Value::from(*ordinal));
361        }
362        Container::Quote => {
363            m.insert("container".into(), "quote".into());
364        }
365        Container::Unknown { tag, attrs } => {
366            m.insert("container".into(), Value::String(tag.clone()));
367            m.insert("attrs".into(), attrs.clone());
368        }
369    }
370    Value::Object(m)
371}
372
373/// Decode a [`Container`] from its canonical wire object. The inverse of
374/// [`container_to_value`].
375pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
376    let o = v.as_object().ok_or(ParseError::Shape("container"))?;
377    let tag = o
378        .get("container")
379        .and_then(Value::as_str)
380        .ok_or(ParseError::Shape("container kind"))?;
381    let o = fold_legacy_attrs(o, tag, Content::RESERVED_CONTAINERS, "container attrs")?;
382    match tag {
383        "list_item" => Ok(Container::ListItem {
384            ordered: o.get("ordered").and_then(Value::as_bool).unwrap_or(false),
385            start: o.get("start").and_then(Value::as_u64).unwrap_or(1),
386            ordinal: o.get("ordinal").and_then(Value::as_u64).unwrap_or(0),
387        }),
388        "quote" => Ok(Container::Quote),
389        // Open set, as for line kinds: an unrecognized container round-trips
390        // opaque and projects transparently.
391        other => Ok(Container::Unknown {
392            tag: other.to_string(),
393            attrs: bag_from_wire(&o, "attrs", "container attrs")?,
394        }),
395    }
396}
397
398// ---- Mark ----
399
400/// Encode a [`Mark`] (`{start, end, type, …}`) into its canonical wire object.
401/// Public so the mark-op wire ([`crate::ops`]) reuses the exact `type`
402/// discriminant a `ContentMark` carries.
403pub fn mark_to_value(mark: &Mark) -> Value {
404    let mut m = Map::new();
405    m.insert("start".into(), Value::from(mark.start));
406    m.insert("end".into(), Value::from(mark.end));
407    match &mark.kind {
408        MarkKind::Strong => {
409            m.insert("type".into(), "strong".into());
410        }
411        MarkKind::Emph => {
412            m.insert("type".into(), "emph".into());
413        }
414        MarkKind::Underline => {
415            m.insert("type".into(), "underline".into());
416        }
417        MarkKind::Strike => {
418            m.insert("type".into(), "strike".into());
419        }
420        MarkKind::Code => {
421            m.insert("type".into(), "code".into());
422        }
423        MarkKind::Link { url } => {
424            m.insert("type".into(), "link".into());
425            m.insert("url".into(), Value::String(url.clone()));
426        }
427        MarkKind::Anchor { id } => {
428            m.insert("type".into(), "anchor".into());
429            m.insert("id".into(), Value::String(id.clone()));
430        }
431        MarkKind::Unknown { tag, attrs } => {
432            m.insert("type".into(), Value::String(tag.clone()));
433            m.insert("attrs".into(), attrs.clone());
434        }
435    }
436    Value::Object(m)
437}
438
439/// What every mark carries whatever its type: the object, the two positions, the
440/// type name.
441struct MarkShape<'a> {
442    fields: &'a Map<String, Value>,
443    start: Usv,
444    end: Usv,
445    ty: &'a str,
446}
447
448/// A mark's fallible half: the prologue of [`mark_from_value`], and on its own
449/// the *whole* of what a caller wanting only the verdict needs.
450///
451/// Building the [`MarkKind`] cannot fail, and for an unknown tag it deep-clones
452/// the opaque `attrs` bag: cost a validity check has no reason to pay
453/// ([`reject_unreadable_mark`]).
454fn mark_shape(v: &Value) -> Result<MarkShape<'_>, ParseError> {
455    let fields = v.as_object().ok_or(ParseError::Shape("mark"))?;
456    let start = usv_from(fields.get("start"), "mark start")?;
457    let end = usv_from(fields.get("end"), "mark end")?;
458    let ty = fields
459        .get("type")
460        .and_then(Value::as_str)
461        .ok_or(ParseError::Shape("mark type"))?;
462    Ok(MarkShape {
463        fields,
464        start,
465        end,
466        ty,
467    })
468}
469
470/// Decode a [`Mark`] from its canonical wire object. The inverse of
471/// [`mark_to_value`]; the shared mark reader for the content decoder and the
472/// mark-op wire.
473pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
474    let MarkShape {
475        fields: o,
476        start,
477        end,
478        ty,
479    } = mark_shape(v)?;
480    // After the shape read, not inside it: the fold's clone is exactly the cost
481    // `mark_shape` exists to let a verdict-only caller skip.
482    let o = fold_legacy_attrs(o, ty, Content::RESERVED_MARK_TYPES, "mark attrs")?;
483    let kind = match ty {
484        "strong" => MarkKind::Strong,
485        "emph" => MarkKind::Emph,
486        "underline" => MarkKind::Underline,
487        "strike" => MarkKind::Strike,
488        "code" => MarkKind::Code,
489        "link" => MarkKind::Link {
490            url: o
491                .get("url")
492                .and_then(Value::as_str)
493                .unwrap_or_default()
494                .to_string(),
495        },
496        "anchor" => MarkKind::Anchor {
497            id: o
498                .get("id")
499                .and_then(Value::as_str)
500                .unwrap_or_default()
501                .to_string(),
502        },
503        // Open set: any other type name is an unknown mark, round-tripped opaque
504        // with whatever `attrs` it carried.
505        other => MarkKind::Unknown {
506            tag: other.to_string(),
507            attrs: bag_from_wire(&o, "attrs", "mark attrs")?,
508        },
509    };
510    Ok(Mark { start, end, kind })
511}
512
513// ---- Authored-lane readers (strict about reserved-name reuse) ----
514//
515// The readers above resolve a built-in discriminator before the `Unknown`
516// fallthrough, so `{"kind": "para", "attrs": {…}}` decodes to `Para` and the
517// `attrs` are dropped unread. `Content::validate`'s reserved-name rule
518// (`Invariant::ReservedUnknownTag` and its two block-axis siblings) never sees
519// such a value: it guards in-process Rust construction, not the wire.
520//
521// The two wire lanes want opposite answers to that drop, and the seam between
522// them is not op-vs-content but authored-now vs read-back:
523//
524// - **Storage** (`Content::from_canonical_json`) stays lenient. A blob written
525//   when `callout` was unknown carries `{"kind": "callout", "attrs": {…}}`, and
526//   the release that makes `callout` a built-in must still open it. Rejecting
527//   `attrs` beside a built-in here refuses documents at rest precisely when the
528//   vocabulary grows: the failure the open set exists to prevent.
529// - **Authored** (the `crate::ops` wire, and `install` through
530//   [`from_authored_value`]) rejects it. The host is writing now, so the shape
531//   means a stale copy of the built-in list, never a document from the past, and
532//   the drop is silent corruption.
533//
534// The rule is narrow on purpose: `attrs` beside a *reserved* name, nothing else.
535// A stray sibling key is evidence of nothing (a line object carries
536// `containers`, an op carries `op`/`line`), and `attrs` is the unknown carrier's
537// own spelling.
538
539/// [`line_kind_from_value`] for the authored lane: `attrs` beside a built-in
540/// `kind` is a shape error rather than a silent drop.
541pub(crate) fn line_kind_from_authored_value(v: &Value) -> Result<LineKind, ParseError> {
542    reject_line_kind_attrs(v)?;
543    line_kind_from_value(v)
544}
545
546/// [`container_from_value`] for the authored lane. See
547/// [`line_kind_from_authored_value`].
548pub(crate) fn container_from_authored_value(v: &Value) -> Result<Container, ParseError> {
549    reject_container_attrs(v)?;
550    container_from_value(v)
551}
552
553/// [`mark_from_value`] for the authored lane. See [`line_kind_from_authored_value`].
554pub(crate) fn mark_from_authored_value(v: &Value) -> Result<Mark, ParseError> {
555    reject_mark_attrs(v)?;
556    mark_from_value(v)
557}
558
559/// The authored lane's verdict on a mark, without building it: everything
560/// [`mark_from_authored_value`] would reject, for a caller that has no use for
561/// the `Mark` itself.
562///
563/// Table cells are that caller, and the only one: [`parse_cell`] reads their
564/// marks leniently, so unlike a prose mark, a cell mark reaches no strict decode
565/// that would raise the error on its own.
566pub(crate) fn reject_unreadable_mark(v: &Value) -> Result<(), ParseError> {
567    reject_mark_attrs(v)?;
568    mark_shape(v)?;
569    Ok(())
570}
571
572/// [`from_canonical_value`] for a content the **host authored just now**: the
573/// `install` input, not a blob read back from storage. Same decode, plus the
574/// reserved-name rule across the whole object, on every axis
575/// [`Content::validate`] checks: line kinds, containers, prose marks, and
576/// table-cell marks.
577///
578/// This is where the silent drop does its damage: an editor lowering a
579/// whole-field diff writes through here on every keystroke.
580pub fn from_authored_value(v: &Value) -> Result<Content, ParseError> {
581    reject_reserved_attrs_deep(v)?;
582    from_canonical_value(v)
583}
584
585/// The authored-lane scan [`from_authored_value`] runs, over the canonical
586/// content shape: the reserved-name rule on every axis, plus a readability check
587/// on table-cell marks, the one axis whose reader is lenient.
588///
589/// Structural on purpose rather than a blind recursive walk: an unknown's
590/// `attrs` is opaque host payload that may legitimately contain an object
591/// spelled `{"type": "link", "attrs": …}`, and rejecting that would make the
592/// carrier unable to carry.
593fn reject_reserved_attrs_deep(v: &Value) -> Result<(), ParseError> {
594    for line in arr_or_empty(v, "lines") {
595        reject_line_kind_attrs(line)?;
596        for c in arr_or_empty(line, "containers") {
597            reject_container_attrs(c)?;
598        }
599    }
600    // Only the reserved-name half here: a prose mark that will not parse is
601    // rejected by the strict decode `from_canonical_value` runs next.
602    for m in arr_or_empty(v, "marks") {
603        reject_mark_attrs(m)?;
604    }
605    // Cell marks ride the prose mark shape, so the rule follows them in, and
606    // the readability check with it, since no strict decode reaches them. The
607    // dispatch goes through `KnownIslandType` like every other one, so a new
608    // mark-carrying type is a compile error here rather than a silent skip.
609    for island in arr_or_empty(v, "islands") {
610        let ty = island.get("type").and_then(Value::as_str).unwrap_or_default();
611        match crate::island::KnownIslandType::parse(ty) {
612            Some(crate::island::KnownIslandType::Table) => {
613                let Some(props) = island.get("props") else {
614                    continue;
615                };
616                for cell in table_cell_values(props) {
617                    for m in arr_or_empty(cell, "marks") {
618                        reject_unreadable_mark(m)?;
619                    }
620                }
621            }
622            // No cells: an image's props are flat, an unknown type's are opaque.
623            Some(crate::island::KnownIslandType::Image) | None => {}
624        }
625    }
626    Ok(())
627}
628
629fn reject_line_kind_attrs(v: &Value) -> Result<(), ParseError> {
630    reject_reserved_attrs(
631        v,
632        "kind",
633        Content::RESERVED_LINE_KINDS,
634        "attrs beside built-in kind",
635    )
636}
637
638fn reject_container_attrs(v: &Value) -> Result<(), ParseError> {
639    reject_reserved_attrs(
640        v,
641        "container",
642        Content::RESERVED_CONTAINERS,
643        "attrs beside built-in container",
644    )
645}
646
647fn reject_mark_attrs(v: &Value) -> Result<(), ParseError> {
648    reject_reserved_attrs(
649        v,
650        "type",
651        Content::RESERVED_MARK_TYPES,
652        "attrs beside built-in mark type",
653    )
654}
655
656/// Error when `v` carries an `attrs` bag alongside a `discriminant` naming a
657/// built-in: the producer meant an unknown and named a known. A non-object or a
658/// missing/non-string discriminant is left to the reader that follows, which
659/// reports the shape error in its own terms.
660///
661/// `attrs` is tested first: it is absent on all but the unknown arms, and
662/// `serde_json` runs with `preserve_order`, so each key probe hashes.
663fn reject_reserved_attrs(
664    v: &Value,
665    discriminant: &str,
666    reserved: &[&str],
667    err: &'static str,
668) -> Result<(), ParseError> {
669    let Some(o) = v.as_object() else {
670        return Ok(());
671    };
672    if !o.contains_key("attrs") {
673        return Ok(());
674    }
675    let Some(tag) = o.get(discriminant).and_then(Value::as_str) else {
676        return Ok(());
677    };
678    if reserved.contains(&tag) {
679        return Err(ParseError::Shape(err));
680    }
681    Ok(())
682}
683
684// ---- Table cell {text, marks} ----
685//
686// A pipe-table cell is inline-only: its own plain `text` plus `marks` whose
687// ranges are USV offsets into that text (0..cell_len). The marks ride the SAME
688// wire shape prose marks use (`mark_to_value`/`mark_from_value`), so nothing
689// forks the encoding. Import builds cells, export/emit render them, and
690// `Content::normalize`/`validate` canonicalize/check the marks: all through
691// these helpers.
692
693/// Parse a table-cell object `{text, marks}` leniently: its plain text plus the
694/// marks over it. A malformed mark is skipped rather than failing: cells are
695/// flat inline, so this never recurses. Public so the typst emitter renders a
696/// cell through the same parse the codecs use.
697pub fn parse_cell(v: &Value) -> (String, Vec<Mark>) {
698    let text = v
699        .get("text")
700        .and_then(Value::as_str)
701        .unwrap_or_default()
702        .to_string();
703    let marks = v
704        .get("marks")
705        .and_then(Value::as_array)
706        .map(|arr| arr.iter().filter_map(|m| mark_from_value(m).ok()).collect())
707        .unwrap_or_default();
708    (text, marks)
709}
710
711/// Build a table-cell object `{text, marks}`: the inverse of [`parse_cell`],
712/// reusing [`mark_to_value`]. Key order is fixed by the recursive key-sort in
713/// [`Content::normalize`], not here.
714pub(crate) fn cell_to_value(text: &str, marks: &[Mark]) -> Value {
715    let mut m = Map::new();
716    m.insert("text".into(), Value::String(text.to_string()));
717    m.insert(
718        "marks".into(),
719        Value::Array(marks.iter().map(mark_to_value).collect()),
720    );
721    Value::Object(m)
722}
723
724/// Every cell object in a table island's props: header then each body row, in
725/// order. The undecoded half of [`table_cells`], so a reader that needs the raw
726/// `Value` walks the same cells in the same order.
727pub(crate) fn table_cell_values(props: &Value) -> impl Iterator<Item = &Value> {
728    let header = arr_or_empty(props, "header").iter();
729    let rows = arr_or_empty(props, "rows")
730        .iter()
731        .flat_map(|row| as_slice(row).iter());
732    header.chain(rows)
733}
734
735/// Every cell's `(text, marks)` in a table island's props: header then each
736/// body row, in order. For [`Content::validate`]'s cell-mark invariant checks.
737pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
738    table_cell_values(props).map(parse_cell).collect()
739}
740
741// The `table` codec below (props normalize, shape-validate, cell extraction) is
742// the primitive `crate::island` dispatches into for `KnownIslandType::Table`;
743// island-type dispatch itself lives there, not here.
744
745/// Repair a table island's props in place to the canonical shape:
746///
747/// - **One column count.** `cols` is the widest of the header, any body row, and
748///   `aligns`; the header, each row, and `aligns` are padded up to it (padding
749///   only grows: no cell is ever truncated). Materializing the count into the
750///   header means the markdown projection (header-derived) and the Typst
751///   projection (widest-row) agree on one number.
752/// - **Single-line cells.** Any `\n`/`\r` in a cell's text becomes a space (the
753///   same rule import applies to soft/hard breaks). A 1:1 replacement keeps char
754///   offsets stable, so the cell's marks stay in range.
755/// - **Canonical cell marks.** Each cell's marks are re-normalized (sort,
756///   same-kind union, drop zero-width) so equal cells serialize to equal bytes.
757pub(crate) fn normalize_table_props(props: &mut Value) {
758    let cols = table_cols(props);
759    let Some(obj) = props.as_object_mut() else {
760        return;
761    };
762    let header = obj.entry("header").or_insert_with(|| Value::Array(vec![]));
763    // A non-array header (a bare string, say) carries no cells; rewrite it to an
764    // empty array so it canonicalizes to a zero-column, content-free table
765    // rather than retaining opaque garbage that `validate` would then reject.
766    if !header.is_array() {
767        *header = Value::Array(vec![]);
768    }
769    pad_row(header, cols);
770    if let Some(h) = header.as_array_mut() {
771        h.iter_mut().for_each(canon_cell);
772    }
773    let aligns = obj.entry("aligns").or_insert_with(|| Value::Array(vec![]));
774    if let Some(a) = aligns.as_array_mut() {
775        while a.len() < cols {
776            a.push(Value::String("none".into()));
777        }
778    }
779    if let Some(rows) = obj.get_mut("rows").and_then(Value::as_array_mut) {
780        for row in rows.iter_mut() {
781            pad_row(row, cols);
782            if let Some(r) = row.as_array_mut() {
783                r.iter_mut().for_each(canon_cell);
784            }
785        }
786    }
787}
788
789/// A table's canonical column count: the widest of its header, any body row, and
790/// its `aligns` array. Padding (never truncation) brings every part up to it.
791fn table_cols(props: &Value) -> usize {
792    let arr_len = |k: &str| props.get(k).and_then(Value::as_array).map(|a| a.len());
793    let header = arr_len("header").unwrap_or(0);
794    let aligns = arr_len("aligns").unwrap_or(0);
795    let widest_row = props
796        .get("rows")
797        .and_then(Value::as_array)
798        .map(|rows| {
799            rows.iter()
800                .map(|r| r.as_array().map(|a| a.len()).unwrap_or(0))
801                .max()
802                .unwrap_or(0)
803        })
804        .unwrap_or(0);
805    header.max(aligns).max(widest_row)
806}
807
808/// Pad a cell array (header or body row) up to `cols` with empty cells. Never
809/// shrinks: `cols` is the widest, so a shorter array only grows.
810fn pad_row(v: &mut Value, cols: usize) {
811    if let Some(arr) = v.as_array_mut() {
812        while arr.len() < cols {
813            arr.push(cell_to_value("", &[]));
814        }
815    }
816}
817
818/// De-newline a cell's text (each `\n`/`\r` → a space, 1:1 so mark offsets hold)
819/// and re-normalize its marks. Reached per-cell from [`normalize_table_props`].
820///
821/// Writes `text` and `marks` back into the cell's **own** object rather than
822/// minting a fresh one, so a key this build does not recognize survives: a
823/// cell is an opaque carrier, not an envelope (`DOCUMENT_STORAGE.md` § Open
824/// vocabularies).
825fn canon_cell(cell: &mut Value) {
826    let (text, marks) = parse_cell(cell);
827    let text = if text.contains(['\n', '\r']) {
828        text.replace(['\n', '\r'], " ")
829    } else {
830        text
831    };
832    let canon = cell_to_value(&text, &crate::model::normalize_marks(marks));
833    match (cell.as_object_mut(), canon) {
834        // Overwrite the canonical keys, leave the rest: the merge
835        // [`crate::ops`] does for a mark's fields on an op object.
836        (Some(o), Value::Object(fields)) => o.extend(fields),
837        // A non-object cell (a bare string, a null) holds no keys to preserve.
838        (_, canon) => *cell = canon,
839    }
840}
841
842/// A table island's shape violation, if any: the widths the header, `aligns`,
843/// and each body row must share (the header width), plus the `\n`-free-cell rule.
844/// The validate-side twin of [`normalize_table_props`].
845pub(crate) fn table_shape_error(props: &Value) -> Option<Invariant> {
846    // A present-but-non-array header can't carry column cells: `normalize`
847    // rewrites it to an empty array, so an un-normalized one is a hand-built
848    // degenerate island. (An absent header is a zero-column table, which is
849    // well-formed: `empty_table_is_valid`.)
850    if props.get("header").is_some_and(|h| !h.is_array()) {
851        return Some(Invariant::TableHeaderNotArray);
852    }
853    let cols = props
854        .get("header")
855        .and_then(Value::as_array)
856        .map(|a| a.len())
857        .unwrap_or(0);
858    let aligns = props
859        .get("aligns")
860        .and_then(Value::as_array)
861        .map(|a| a.len())
862        .unwrap_or(0);
863    if aligns != cols {
864        return Some(Invariant::TableAlignsMismatch { aligns, cols });
865    }
866    if let Some(rows) = props.get("rows").and_then(Value::as_array) {
867        for (i, row) in rows.iter().enumerate() {
868            let width = row.as_array().map(|a| a.len()).unwrap_or(0);
869            if width != cols {
870                return Some(Invariant::TableRaggedRow {
871                    row: i,
872                    width,
873                    cols,
874                });
875            }
876        }
877    }
878    for (i, (text, _)) in table_cells(props).iter().enumerate() {
879        if text.contains('\n') || text.contains('\r') {
880            return Some(Invariant::TableCellNewline { cell: i });
881        }
882    }
883    None
884}
885
886// ---- Island ----
887
888pub(crate) fn island_to_value(island: &Island) -> Value {
889    let mut m = Map::new();
890    m.insert("id".into(), Value::String(island.id.clone()));
891    m.insert("type".into(), Value::String(island.island_type.clone()));
892    m.insert("props".into(), island.props.clone());
893    m.insert("loss".into(), island.loss.as_str().into());
894    Value::Object(m)
895}
896
897pub(crate) fn island_from_value(v: &Value) -> Result<Island, ParseError> {
898    let o = v.as_object().ok_or(ParseError::Shape("island"))?;
899    Ok(Island {
900        id: o
901            .get("id")
902            .and_then(Value::as_str)
903            .ok_or(ParseError::Shape("island id"))?
904            .to_string(),
905        island_type: o
906            .get("type")
907            .and_then(Value::as_str)
908            .ok_or(ParseError::Shape("island type"))?
909            .to_string(),
910        props: bag_from_wire(o, "props", "island props")?,
911        // The class is carried whether or not this build interprets it, and
912        // reads through `Loss::fidelity` at the *safe* end: never claim a class
913        // the reader cannot interpret "carries faithfully". A missing key is the
914        // faithful class, which is what an island with no loss recorded means.
915        loss: o
916            .get("loss")
917            .and_then(Value::as_str)
918            .map_or(Loss::LOSSLESS, Loss::new),
919    })
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::model::{Fidelity, Line, LineKind};
926
927    fn sample() -> Content {
928        Content {
929            text: "hello world".into(),
930            lines: vec![Line {
931                kind: LineKind::Para,
932                containers: vec![],
933                continues: false,
934            }],
935            marks: vec![
936                Mark {
937                    start: 6,
938                    end: 11,
939                    kind: MarkKind::Strong,
940                },
941                Mark {
942                    start: 0,
943                    end: 5,
944                    kind: MarkKind::Emph,
945                },
946            ],
947            islands: vec![],
948        }
949    }
950
951    /// The decoder is the entry point for stored and
952    /// caller-supplied content, and export recurses one frame per container. A
953    /// 20 000-deep path that decoded clean would abort the process on
954    /// `to_markdown`; the shared `validate` cap rejects it at the door.
955    #[test]
956    fn deep_container_nesting_is_rejected_at_decode() {
957        let containers = vec![r#"{"container":"quote"}"#; 20_000].join(",");
958        let json = format!(
959            r#"{{"text":"hi","lines":[{{"kind":"para","containers":[{containers}]}}],"marks":[],"islands":[]}}"#
960        );
961        assert!(matches!(
962            Content::from_canonical_json(&json),
963            Err(ParseError::Invalid(Invariant::NestingTooDeep { .. }))
964        ));
965    }
966
967    /// Build a `Value` nesting `depth` array levels: iteratively, so *building*
968    /// the fixture cannot overflow. Handling it still can: `Value`'s `Clone` and
969    /// `Drop` both recurse, so the tests below probe just past the cap at 1 000
970    /// (deep enough to be refused, shallow enough to pass around) rather than at
971    /// a depth that overflows the test itself. A depth the guard must reject is a
972    /// depth the test cannot hold either, which is the reason the limit exists.
973    fn nested_arrays(depth: usize) -> Value {
974        let mut v = Value::Null;
975        for _ in 0..depth {
976            v = Value::Array(vec![v]);
977        }
978        v
979    }
980
981    /// [`deep_container_nesting_is_rejected_at_decode`] on the
982    /// payload axis, through the `Value` lane. The string lane is bounded by its
983    /// parser (`serde_json::from_str` refuses past 128); the `Value` lane is the
984    /// host-authored one (`install` reaches it) and has to refuse the same
985    /// shape, since an unguarded deep `props` aborts the process rather than
986    /// erroring.
987    #[test]
988    fn deep_json_payload_is_rejected_at_decode_on_the_value_lane() {
989        let deep = nested_arrays(1_000);
990        let cases: [(Value, &'static str); 4] = [
991            (
992                serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
993                  "marks":[],"islands":[{"id":"i1","type":"widget","loss":"lossless","props":deep}]}),
994                "island props",
995            ),
996            (
997                serde_json::json!({"text":"x","lines":[{"kind":"para","containers":[]}],
998                  "marks":[{"start":0,"end":1,"type":"sparkle","attrs":deep}],"islands":[]}),
999                "mark attrs",
1000            ),
1001            (
1002                serde_json::json!({"text":"x","lines":[{"kind":"callout","containers":[],"attrs":deep}],
1003                  "marks":[],"islands":[]}),
1004                "line attrs",
1005            ),
1006            (
1007                serde_json::json!({"text":"x","lines":[{"kind":"para",
1008                  "containers":[{"container":"indent","attrs":deep}]}],"marks":[],"islands":[]}),
1009                "container attrs",
1010            ),
1011        ];
1012        for (v, what) in cases {
1013            assert_eq!(
1014                from_canonical_value(&v),
1015                Err(ParseError::Invalid(Invariant::JsonTooDeep {
1016                    what,
1017                    max: crate::MAX_JSON_DEPTH,
1018                })),
1019                "{what} accepted a 1 000-deep payload"
1020            );
1021            // The authored lane funnels through the same decode, so it refuses
1022            // the same shape rather than trapping on the reserved-name scan.
1023            assert!(matches!(
1024                from_authored_value(&v),
1025                Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1026            ));
1027        }
1028    }
1029
1030    /// The cap admits every payload a stored blob can carry, so closing the
1031    /// `Value` lane costs no stored population. Stated as the implication rather
1032    /// than an offset: `serde_json::from_str`'s own limit counts from the document
1033    /// root, not from the bag, so the wrapper levels it also charges are its
1034    /// business, what must hold is that anything the string lane delivers, the
1035    /// per-bag cap accepts.
1036    #[test]
1037    fn json_depth_cap_admits_every_storable_payload() {
1038        let content = |props: Value| {
1039            serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
1040              "marks":[],"islands":[{"id":"i1","type":"widget","loss":"lossless","props":props}]})
1041        };
1042        assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH))).is_ok());
1043        assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH + 1))).is_err());
1044
1045        // Across the whole boundary region, string-lane-accepted implies
1046        // `Value`-lane-accepted. The converse does not hold and need not: the
1047        // string lane's root-relative count refuses a few depths the bag cap
1048        // allows.
1049        let mut storable = 0;
1050        for d in 1..=crate::MAX_JSON_DEPTH + 8 {
1051            let v = content(nested_arrays(d));
1052            if Content::from_canonical_json(&v.to_string()).is_ok() {
1053                storable = d;
1054                assert!(
1055                    from_canonical_value(&v).is_ok(),
1056                    "the bag cap refused a {d}-deep props the string lane accepts"
1057                );
1058            }
1059        }
1060        assert!(
1061            storable > 0 && storable <= crate::MAX_JSON_DEPTH,
1062            "string lane's deepest storable props was {storable}"
1063        );
1064    }
1065
1066    /// The legacy-attrs fold is the one frame that spends the depth
1067    /// without retaining the bag: it deep-clones the object it folds into, and it
1068    /// runs for a *built-in* name, where no `Unknown` arm reads the bag at all. A
1069    /// nested-object bag, since only an object folds.
1070    #[test]
1071    fn deep_json_payload_is_rejected_before_the_legacy_attrs_fold() {
1072        let mut deep = Value::Null;
1073        for _ in 0..1_000 {
1074            deep = serde_json::json!({"a": deep});
1075        }
1076        // `para` is reserved, `attrs` is a non-empty object: the fold path.
1077        let v = serde_json::json!({"text":"x","lines":[{"kind":"para","containers":[],"attrs":deep}],
1078          "marks":[],"islands":[]});
1079        assert_eq!(
1080            from_canonical_value(&v),
1081            Err(ParseError::Invalid(Invariant::JsonTooDeep {
1082                what: "line attrs",
1083                max: crate::MAX_JSON_DEPTH,
1084            }))
1085        );
1086    }
1087
1088    /// An over-deep bag is refused whichever door it arrives at, so
1089    /// the op wire cannot install one either.
1090    #[test]
1091    fn deep_json_payload_is_rejected_on_the_op_wire() {
1092        let deep = nested_arrays(1_000);
1093        let op = serde_json::json!({"op":"add","start":0,"end":1,"type":"sparkle","attrs":deep});
1094        assert!(matches!(
1095            crate::ops::mark_op_from_value(&op),
1096            Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1097        ));
1098        let op = serde_json::json!({"op":"setKind","line":0,"kind":"callout","attrs":deep});
1099        assert!(matches!(
1100            crate::ops::line_op_from_value(&op),
1101            Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1102        ));
1103    }
1104
1105    /// A wire position past `usize` is refused, not truncated. On
1106    /// wasm32 (the deployment target) `as usize` turned `2^32 + 5` into an
1107    /// in-range `5`, landing a mark at the wrong position in a document that
1108    /// then validated clean. Rejected on every target, by the checked read here
1109    /// on 32-bit and by the range invariant on 64-bit.
1110    #[test]
1111    fn out_of_range_wire_position_is_refused() {
1112        let json = r#"{"text":"hello","lines":[{"kind":"para","containers":[]}],"marks":[{"start":4294967301,"end":4294967302,"type":"strong"}],"islands":[]}"#;
1113        assert!(Content::from_canonical_json(json).is_err());
1114        assert!(usv_from(Some(&Value::from(u64::MAX)), "x").is_ok() || usize::BITS < 64);
1115        assert!(usv_from(Some(&Value::from(-1i64)), "x").is_err());
1116    }
1117
1118    #[test]
1119    fn island_props_key_order_does_not_leak() {
1120        let mut one = Content::empty();
1121        one.text = "\u{FFFC}".into();
1122        one.lines = vec![Line {
1123            kind: LineKind::Island,
1124            containers: vec![],
1125            continues: false,
1126        }];
1127        one.islands = vec![Island {
1128            id: "i1".into(),
1129            island_type: "table".into(),
1130            props: serde_json::json!({"b": 1, "a": 2}),
1131            loss: Loss::LOSSLESS,
1132        }];
1133        let mut two = one.clone();
1134        two.islands[0].props = serde_json::json!({"a": 2, "b": 1}); // keys reversed
1135        assert_eq!(one.to_canonical_json(), two.to_canonical_json());
1136    }
1137
1138    #[test]
1139    fn golden_bytes_are_feature_independent() {
1140        // Pins the exact canonical form. Every object key is sorted, so the
1141        // bytes do not depend on serde_json's preserve_order feature. If this
1142        // string changes, the freeze changed: bump the schema version.
1143        let rt = sample();
1144        assert_eq!(
1145            rt.to_canonical_json(),
1146            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":5,"start":0,"type":"emph"},{"end":11,"start":6,"type":"strong"}],"text":"hello world"}"#
1147        );
1148    }
1149
1150    #[test]
1151    fn from_canonical_json_rejects_invalid() {
1152        // lines.len() != segment count: must not silently round-trip.
1153        let bad =
1154            r#"{"text":"a\nb","lines":[{"kind":"para","containers":[]}],"marks":[],"islands":[]}"#;
1155        assert!(matches!(
1156            Content::from_canonical_json(bad),
1157            Err(ParseError::Invalid(_))
1158        ));
1159    }
1160
1161    #[test]
1162    fn reserved_unknown_tag_rejected() {
1163        // An Unknown mark may not reuse a built-in type name (would parse back
1164        // as the built-in, dropping attrs: non-injective).
1165        let mut rt = Content::empty();
1166        rt.text = "abcd".into();
1167        rt.marks = vec![Mark {
1168            start: 0,
1169            end: 4,
1170            kind: MarkKind::Unknown {
1171                tag: "strong".into(),
1172                attrs: serde_json::json!({}),
1173            },
1174        }];
1175        assert!(matches!(
1176            rt.validate(),
1177            Err(crate::model::Invariant::ReservedUnknownTag(_))
1178        ));
1179    }
1180
1181    /// `loss` is an open vocabulary on [`Island::island_type`]'s terms, not the
1182    /// block axes'. A class this build lacks is **carried**, not rewritten, so a
1183    /// reader that merely opens the document neither destroys the class nor
1184    /// moves the content hash. Reading it degrades to the safe end.
1185    #[test]
1186    fn unknown_loss_class_round_trips_and_reads_unrepresentable() {
1187        let json = concat!(
1188            r#"{"islands":[{"id":"i1","loss":"partial","props":{},"type":"widget"}],"#,
1189            r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1190        );
1191        let rt = Content::from_canonical_json(json).unwrap();
1192        assert_eq!(rt.islands[0].loss, Loss::new("partial"));
1193        assert_eq!(rt.islands[0].loss.fidelity(), Fidelity::Unrepresentable);
1194        assert_eq!(rt.to_canonical_json(), json);
1195    }
1196
1197    /// The class is the stored value, so a built-in's name has one spelling and
1198    /// the reserved-name rule the block axes need has nothing to guard: what a
1199    /// caller hand-builds from that name **is** the built-in, and survives the
1200    /// round trip as itself.
1201    #[test]
1202    fn a_built_in_class_name_has_one_spelling() {
1203        assert_eq!(Loss::new("lossless"), Loss::LOSSLESS);
1204        let mut rt = Content::empty();
1205        rt.text = "\u{FFFC}".into();
1206        rt.lines = vec![Line {
1207            kind: LineKind::Island,
1208            containers: vec![],
1209            continues: false,
1210        }];
1211        rt.islands = vec![Island {
1212            id: "i1".into(),
1213            island_type: "widget".into(),
1214            props: serde_json::json!({}),
1215            loss: Loss::new("lossless"),
1216        }];
1217        assert_eq!(rt.validate(), Ok(()));
1218        let back = Content::from_canonical_json(&rt.to_canonical_json()).unwrap();
1219        assert_eq!(back.islands[0].loss, rt.islands[0].loss);
1220        assert_eq!(back.islands[0].loss.fidelity(), Fidelity::Lossless);
1221    }
1222
1223    /// Every class `Fidelity` names round-trips to its own level, so the closed
1224    /// view and the wire spellings cannot drift apart.
1225    #[test]
1226    fn every_fidelity_level_round_trips_through_its_class() {
1227        for &f in Fidelity::ALL {
1228            assert_eq!(Loss::new(f.as_str()).fidelity(), f);
1229        }
1230    }
1231
1232    /// The block vocabulary is open on the mark axis' terms. A
1233    /// `kind`/`container` this build lacks decodes to `Unknown` (the document
1234    /// **opens**) and re-encodes byte-identically, so a construct a future
1235    /// reader understands survives the trip through this one.
1236    #[test]
1237    fn unknown_line_kind_and_container_round_trip_opaque() {
1238        let json = concat!(
1239            r#"{"islands":[],"lines":[{"attrs":{"variant":"warn"},"containers":"#,
1240            r#"[{"attrs":{"depth":2},"container":"indent"}],"kind":"callout"}],"#,
1241            r#""marks":[],"text":"heads up"}"#
1242        );
1243        let rt = Content::from_canonical_json(json).unwrap();
1244        assert_eq!(
1245            rt.lines[0].kind,
1246            LineKind::Unknown {
1247                tag: "callout".into(),
1248                attrs: serde_json::json!({"variant": "warn"}),
1249            }
1250        );
1251        assert_eq!(
1252            rt.lines[0].containers,
1253            vec![Container::Unknown {
1254                tag: "indent".into(),
1255                attrs: serde_json::json!({"depth": 2}),
1256            }]
1257        );
1258        assert_eq!(rt.to_canonical_json(), json);
1259        // An attrs-free unknown decodes too (`attrs` is null, not a shape error).
1260        let bare = r#"{"islands":[],"lines":[{"containers":[],"kind":"footnote"}],"marks":[],"text":"x"}"#;
1261        let rt = Content::from_canonical_json(bare).unwrap();
1262        assert_eq!(
1263            rt.lines[0].kind,
1264            LineKind::Unknown {
1265                tag: "footnote".into(),
1266                attrs: Value::Null,
1267            }
1268        );
1269        // A missing/non-string discriminator is still a shape error: the open
1270        // set absorbs unknown *names*, not malformed objects.
1271        for bad in [
1272            r#"{"islands":[],"lines":[{"containers":[]}],"marks":[],"text":"x"}"#,
1273            r#"{"islands":[],"lines":[{"containers":[{"container":7}],"kind":"para"}],"marks":[],"text":"x"}"#,
1274        ] {
1275            assert!(matches!(
1276                Content::from_canonical_json(bad),
1277                Err(ParseError::Shape(_))
1278            ));
1279        }
1280    }
1281
1282    /// An unknown line kind / container may not reuse a built-in
1283    /// name: it would serialize as the built-in and parse back as one, dropping
1284    /// its attrs (the `ReservedUnknownTag` rule, one axis over).
1285    #[test]
1286    fn reserved_block_vocabulary_names_rejected() {
1287        let mut rt = Content::empty();
1288        rt.text = "abcd".into();
1289        rt.lines[0].kind = LineKind::Unknown {
1290            tag: "heading".into(),
1291            attrs: serde_json::json!({}),
1292        };
1293        assert_eq!(
1294            rt.validate(),
1295            Err(Invariant::ReservedUnknownLineKind("heading".into()))
1296        );
1297        rt.lines[0].kind = LineKind::Para;
1298        rt.lines[0].containers = vec![Container::Unknown {
1299            tag: "quote".into(),
1300            attrs: serde_json::json!({}),
1301        }];
1302        assert_eq!(
1303            rt.validate(),
1304            Err(Invariant::ReservedUnknownContainer("quote".into()))
1305        );
1306    }
1307
1308    /// The authored lane (`install`) applies the reserved-name rule
1309    /// the decoders cannot: by the time a lenient reader has resolved `"para"`
1310    /// to `Para`, the `attrs` are gone and `validate` has nothing to object to.
1311    /// Every axis `validate` checks, including cell marks.
1312    ///
1313    /// The last case: a cell mark that will not parse at all.
1314    /// It is the one axis with no strict decode behind it (`parse_cell` skips
1315    /// what it cannot read and `canon_cell` makes the skip permanent) so
1316    /// without this the host's mark vanishes with no signal.
1317    #[test]
1318    fn authored_lane_rejects_attrs_beside_a_built_in_name() {
1319        let bad = [
1320            // line kind
1321            r#"{"islands":[],"lines":[{"attrs":{"tone":"warn"},"containers":[],"kind":"para"}],"marks":[],"text":"x"}"#,
1322            // container
1323            r#"{"islands":[],"lines":[{"containers":[{"attrs":{},"container":"quote"}],"kind":"para"}],"marks":[],"text":"x"}"#,
1324            // prose mark
1325            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"attrs":{},"end":1,"start":0,"type":"strong"}],"text":"x"}"#,
1326            // table cell mark
1327            concat!(
1328                r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
1329                r#""header":[{"marks":[{"attrs":{},"end":1,"start":0,"type":"emph"}],"text":"h"}],"#,
1330                r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
1331                r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1332            ),
1333            // table cell mark with no `type` at all
1334            concat!(
1335                r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
1336                r#""header":[{"marks":[{"end":1,"start":0}],"text":"h"}],"#,
1337                r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
1338                r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1339            ),
1340        ];
1341        for json in bad {
1342            let v: Value = serde_json::from_str(json).unwrap();
1343            assert!(
1344                matches!(from_authored_value(&v), Err(ParseError::Shape(_))),
1345                "accepted: {json}"
1346            );
1347            // The storage lane opens all five: a document written before the
1348            // name was built in must keep loading.
1349            assert!(
1350                Content::from_canonical_json(json).is_ok(),
1351                "storage lane rejected: {json}"
1352            );
1353        }
1354        // …and what storage does with the unreadable one: skips it, keeping the
1355        // document openable.
1356        let rt = Content::from_canonical_json(bad[4]).unwrap();
1357        assert!(rt.islands[0].props["header"][0]["marks"]
1358            .as_array()
1359            .unwrap()
1360            .is_empty());
1361    }
1362
1363    /// The authored lane's scan is structural, not a blind walk. An
1364    /// unknown's `attrs` is opaque host payload and may contain an object spelled
1365    /// like a reserved mark; rejecting that would make the carrier unable to
1366    /// carry the thing it exists to carry.
1367    #[test]
1368    fn authored_lane_leaves_opaque_attrs_payload_alone() {
1369        let json = concat!(
1370            r#"{"islands":[],"lines":[{"attrs":{"nested":{"attrs":{},"type":"link"}},"#,
1371            r#""containers":[],"kind":"callout"}],"marks":[],"text":"x"}"#
1372        );
1373        let v: Value = serde_json::from_str(json).unwrap();
1374        let rt = from_authored_value(&v).unwrap();
1375        assert_eq!(rt.to_canonical_json(), json);
1376    }
1377
1378    /// Opaque block attrs are hash input, so their key order must
1379    /// not leak into the canonical bytes: the unknown-mark rule, one axis over.
1380    #[test]
1381    fn unknown_block_attrs_key_order_does_not_leak() {
1382        let mut one = Content::empty();
1383        one.text = "hi".into();
1384        one.lines[0].kind = LineKind::Unknown {
1385            tag: "callout".into(),
1386            attrs: serde_json::json!({"b": 1, "a": 2}),
1387        };
1388        one.lines[0].containers = vec![Container::Unknown {
1389            tag: "indent".into(),
1390            attrs: serde_json::json!({"y": 1, "x": 2}),
1391        }];
1392        let mut two = one.clone();
1393        two.lines[0].kind = LineKind::Unknown {
1394            tag: "callout".into(),
1395            attrs: serde_json::json!({"a": 2, "b": 1}),
1396        };
1397        two.lines[0].containers = vec![Container::Unknown {
1398            tag: "indent".into(),
1399            attrs: serde_json::json!({"x": 2, "y": 1}),
1400        }];
1401        assert_eq!(one.to_canonical_json(), two.to_canonical_json());
1402        one.normalize();
1403        two.normalize();
1404        assert_eq!(one, two, "normalize canonicalizes the live model too");
1405    }
1406
1407    #[test]
1408    fn unknown_mark_round_trips_opaque() {
1409        let mut rt = Content::empty();
1410        rt.text = "abcd".into();
1411        rt.marks = vec![Mark {
1412            start: 0,
1413            end: 4,
1414            kind: MarkKind::Unknown {
1415                tag: "highlight".into(),
1416                attrs: serde_json::json!({"color": "yellow"}),
1417            },
1418        }];
1419        let json = rt.to_canonical_json();
1420        let back = Content::from_canonical_json(&json).unwrap();
1421        assert_eq!(back.marks[0].kind, rt.marks[0].kind);
1422    }
1423
1424    /// Promotion moves a construct's payload from the opaque bag to
1425    /// named siblings, and every blob written before it still spells the payload
1426    /// the old way. The storage lane folds the bag in, so the promoted decoder
1427    /// reads what the unknown wrote instead of dropping it. Pinned on the
1428    /// built-ins carrying payload today: the fold keys off `RESERVED_*`, so a
1429    /// promoted name joins it by the same edit that promotes it.
1430    #[test]
1431    fn built_in_decoders_read_the_legacy_attrs_form() {
1432        let cases: [(Value, LineKind); 2] = [
1433            (
1434                serde_json::json!({"kind": "heading", "attrs": {"level": 2}}),
1435                LineKind::Heading { level: 2 },
1436            ),
1437            (
1438                serde_json::json!({"kind": "code", "attrs": {"lang": "rust"}}),
1439                LineKind::Code {
1440                    lang: Some("rust".into()),
1441                },
1442            ),
1443        ];
1444        for (v, want) in cases {
1445            assert_eq!(line_kind_from_value(&v).unwrap(), want);
1446        }
1447        let item = serde_json::json!({
1448            "container": "list_item",
1449            "attrs": {"ordered": true, "start": 3, "ordinal": 1}
1450        });
1451        assert_eq!(
1452            container_from_value(&item).unwrap(),
1453            Container::ListItem {
1454                ordered: true,
1455                start: 3,
1456                ordinal: 1,
1457            }
1458        );
1459        let link = serde_json::json!({"start": 0, "end": 1, "type": "link", "attrs": {"url": "u"}});
1460        assert_eq!(
1461            mark_from_value(&link).unwrap().kind,
1462            MarkKind::Link { url: "u".into() }
1463        );
1464        // Both spellings present: the named sibling is the canonical one.
1465        let both = serde_json::json!({"kind": "heading", "level": 3, "attrs": {"level": 2}});
1466        assert_eq!(
1467            line_kind_from_value(&both).unwrap(),
1468            LineKind::Heading { level: 3 }
1469        );
1470        // An unknown's bag is payload, not a source of named fields; including a
1471        // key that would re-target the match if the fold read the discriminator
1472        // back out of it.
1473        let unknown = serde_json::json!({"kind": "callout", "attrs": {"kind": "heading", "level": 2}});
1474        assert_eq!(
1475            line_kind_from_value(&unknown).unwrap(),
1476            LineKind::Unknown {
1477                tag: "callout".into(),
1478                attrs: serde_json::json!({"kind": "heading", "level": 2}),
1479            }
1480        );
1481        // Re-encode is the promoted spelling, so opening a legacy blob under the
1482        // release that promotes its tag moves the document's canonical bytes:
1483        // the read-repair / accepted-movement case § Byte-stability governs.
1484        let legacy = r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":[],"kind":"heading"}],"marks":[],"text":"hi"}"#;
1485        assert_eq!(
1486            Content::from_canonical_json(legacy)
1487                .unwrap()
1488                .to_canonical_json(),
1489            r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"hi"}"#
1490        );
1491    }
1492
1493    /// `ord` is part of the freeze, and a promoted type takes the
1494    /// slot `Unknown` held. Anywhere else and a build that knows the type orders
1495    /// it against the built-ins differently from a build that reads it as
1496    /// `Unknown`: one document, two canonical forms.
1497    #[test]
1498    fn unknown_holds_the_last_mark_ordinal() {
1499        let all = [
1500            MarkKind::Strong,
1501            MarkKind::Emph,
1502            MarkKind::Underline,
1503            MarkKind::Strike,
1504            MarkKind::Code,
1505            MarkKind::Link { url: "u".into() },
1506            MarkKind::Anchor { id: "a".into() },
1507            MarkKind::Unknown {
1508                tag: "kbd".into(),
1509                attrs: Value::Null,
1510            },
1511        ];
1512        // Exhaustive on purpose: a new variant is a compile error here, which is
1513        // where the placement rule gets read, rather than a slot silently taken
1514        // after `Unknown`.
1515        for k in &all {
1516            match k {
1517                MarkKind::Strong
1518                | MarkKind::Emph
1519                | MarkKind::Underline
1520                | MarkKind::Strike
1521                | MarkKind::Code
1522                | MarkKind::Link { .. }
1523                | MarkKind::Anchor { .. }
1524                | MarkKind::Unknown { .. } => {}
1525            }
1526        }
1527        let ords: Vec<u8> = all.iter().map(MarkKind::ord).collect();
1528        assert_eq!(ords, (0..all.len() as u8).collect::<Vec<_>>());
1529        assert!(matches!(all.last(), Some(MarkKind::Unknown { .. })));
1530    }
1531
1532    /// Formatting-class membership is stored meaning. Two adjacent
1533    /// unknowns are two marks; two adjacent formatting marks are one. Promoting a
1534    /// tag into the class therefore rewrites documents nobody edited, which makes
1535    /// it a canonical-byte event rather than a silent widening.
1536    #[test]
1537    fn formatting_class_membership_decides_adjacent_union() {
1538        let mut rt = Content::empty();
1539        rt.text = "abcd".into();
1540        let unknown = |start, end| Mark {
1541            start,
1542            end,
1543            kind: MarkKind::Unknown {
1544                tag: "kbd".into(),
1545                attrs: serde_json::json!({}),
1546            },
1547        };
1548        rt.marks = vec![unknown(0, 2), unknown(2, 4)];
1549        rt.normalize();
1550        assert_eq!(rt.marks.len(), 2);
1551        rt.marks = vec![
1552            Mark {
1553                start: 0,
1554                end: 2,
1555                kind: MarkKind::Strong,
1556            },
1557            Mark {
1558                start: 2,
1559                end: 4,
1560                kind: MarkKind::Strong,
1561            },
1562        ];
1563        rt.normalize();
1564        assert_eq!(rt.marks.len(), 1);
1565    }
1566
1567    /// Promotion grows `RESERVED_*`, and the authored lane then
1568    /// refuses a shape it accepted the release before. By design: a host still
1569    /// authoring the unknown spelling of a name that now means the built-in is
1570    /// writing the silent drop the rule exists to catch, but from the host's
1571    /// side it reads as a release breaking its writes.
1572    #[test]
1573    fn reserved_growth_flips_authored_acceptance() {
1574        let doc = |kind: &str| {
1575            serde_json::json!({
1576                "islands": [],
1577                "lines": [{"attrs": {"level": 2}, "containers": [], "kind": kind}],
1578                "marks": [],
1579                "text": "hi",
1580            })
1581        };
1582        // Outside `RESERVED_LINE_KINDS` today: an unknown carrying its payload.
1583        assert!(from_authored_value(&doc("callout")).is_ok());
1584        // Inside it: what `"callout"` becomes the release it is promoted.
1585        assert!(matches!(
1586            from_authored_value(&doc("heading")),
1587            Err(ParseError::Shape(_))
1588        ));
1589    }
1590}