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//! (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`, what a
12//! consumer hashing stored documents spends. **Values**:
13//! `from_canonical_json(to_canonical_json(rt)) == rt` for a normalized `rt`,
14//! which holds only while every discriminator's encoding is injective. An axis
15//! can keep the first and lose the second: a value that encodes to some *other*
16//! value's bytes moves nothing on disk and still fails its own round trip.
17//!
18//! Storage, the render seam and the binding seam carry one canonical form.
19
20use crate::model::{
21    canonicalize_keys, Container, Island, Line, LineKind, Loss, Mark,
22    MarkKind, Content, Normalized, Usv,
23};
24use serde_json::{Map, Value};
25use std::borrow::Cow;
26
27/// Why canonical-JSON parsing failed. Structural only: a well-formed producer
28/// (this crate's serializer, storage, a binding) never trips these.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ParseError {
31    /// Top-level JSON was not an object, or a required key was missing/mistyped.
32    Shape(&'static str),
33    /// The JSON itself did not parse.
34    Json(String),
35    /// The value parsed but violates a content invariant.
36    Invalid(crate::model::Invariant),
37    /// A discriminator outside its vocabulary. `axis` names the wire field
38    /// (`line kind`, `container`, `mark type`, `island type`, `island loss`),
39    /// `name` the value it carried.
40    UnknownName {
41        axis: &'static str,
42        name: String,
43    },
44}
45
46impl std::fmt::Display for ParseError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            ParseError::Shape(s) => write!(f, "content json shape: {s}"),
50            ParseError::Json(s) => write!(f, "content json parse: {s}"),
51            ParseError::Invalid(inv) => write!(f, "content invariant: {inv:?}"),
52            ParseError::UnknownName { axis, name } => {
53                write!(f, "content vocabulary: unknown {axis} {name:?}")
54            }
55        }
56    }
57}
58impl std::error::Error for ParseError {}
59
60impl Normalized {
61    /// Serialize to canonical JSON bytes. Every object key comes out in
62    /// ascending order at every depth, so the bytes do **not** depend on
63    /// `serde_json`'s `preserve_order` feature in the consumer's crate graph.
64    ///
65    /// On [`Normalized`] for the reason that token exists: canonical bytes need
66    /// canonical input, and the mint is where a caller's mark/island order
67    /// settles.
68    pub fn to_canonical_json(&self) -> String {
69        to_canonical_value(self).to_string()
70    }
71}
72
73impl Content {
74    /// Parse canonical JSON, normalize, and validate. Returns
75    /// [`ParseError::Invalid`] for a content that violates its invariants, so
76    /// storage cannot silently round-trip a malformed value.
77    pub fn from_canonical_json(s: &str) -> Result<Normalized, ParseError> {
78        let v: Value = serde_json::from_str(s).map_err(|e| ParseError::Json(e.to_string()))?;
79        from_canonical_value(&v)
80    }
81
82    fn to_value(&self) -> Value {
83        let mut root = Map::new();
84        root.insert(
85            "islands".into(),
86            Value::Array(self.islands.iter().map(island_to_value).collect()),
87        );
88        root.insert(
89            "lines".into(),
90            Value::Array(self.lines.iter().map(line_to_value).collect()),
91        );
92        root.insert(
93            "marks".into(),
94            Value::Array(self.marks.iter().map(mark_to_value).collect()),
95        );
96        root.insert("text".into(), Value::String(self.text.clone()));
97        Value::Object(root)
98    }
99
100    fn from_value(v: &Value) -> Result<Content, ParseError> {
101        let obj = v.as_object().ok_or(ParseError::Shape("root not object"))?;
102        let text = obj
103            .get("text")
104            .and_then(Value::as_str)
105            .ok_or(ParseError::Shape("text"))?
106            .to_string();
107        let lines = arr(obj, "lines")?
108            .iter()
109            .map(line_from_value)
110            .collect::<Result<_, _>>()?;
111        let marks = arr(obj, "marks")?
112            .iter()
113            .map(mark_from_value)
114            .collect::<Result<_, _>>()?;
115        let islands = arr(obj, "islands")?
116            .iter()
117            .map(island_from_value)
118            .collect::<Result<_, _>>()?;
119        Ok(Content {
120            text,
121            lines,
122            marks,
123            islands,
124        })
125    }
126}
127
128/// The canonical form as a structural [`Value`]: the recursively key-sorted
129/// tree [`Normalized::to_canonical_json`] renders to bytes. A storage layer
130/// embeds this as a nested object rather than an escaped string; serializing it
131/// with `serde_json` is byte-identical to that JSON, independent of the
132/// consumer's `preserve_order` feature.
133pub fn to_canonical_value(rt: &Normalized) -> Value {
134    let mut v = rt.to_value();
135    // Scans and returns: the encoders emit their fixed keys in ascending order,
136    // and every opaque bag under them was canonicalized by the mint. A key
137    // inserted out of order is still repaired here, so the freeze holds without
138    // the encoders having to be trusted for it.
139    canonicalize_keys(&mut v);
140    v
141}
142
143/// One map's own keys in ascending order, its values untouched. Shallow because
144/// every value below is a scalar, an array of encoder objects that sorted
145/// themselves, or a bag [`Content::normalize`] already canonicalized.
146fn sort_own_keys(m: Map<String, Value>) -> Map<String, Value> {
147    let mut entries: Vec<(String, Value)> = m.into_iter().collect();
148    entries.sort_by(|a, b| a.0.cmp(&b.0));
149    entries.into_iter().collect()
150}
151
152/// Parse the canonical content form from a structural [`Value`], normalize, and
153/// validate: the [`Value`]-input counterpart to
154/// [`Content::from_canonical_json`].
155pub fn from_canonical_value(v: &Value) -> Result<Normalized, ParseError> {
156    seal(Content::from_value(v)?)
157}
158
159/// Mint and check: the tail both wire lanes share, after the authored lane has
160/// read its own refusals off the decode.
161fn seal(rt: Content) -> Result<Normalized, ParseError> {
162    let rt = rt.into_normalized();
163    rt.validate().map_err(ParseError::Invalid)?;
164    Ok(rt)
165}
166
167/// Read an opaque payload bag (`attrs`, `props`) off the wire, absent → `Null`.
168///
169/// **Depth-checked before the clone.** `Value::clone` spends a frame per level,
170/// so an over-deep bag has to be refused while it is still borrowed from the
171/// caller's `Value`: once owned the frames are already spent, and dropping it
172/// spends them again.
173fn bag_from_wire(
174    o: &Map<String, Value>,
175    key: &'static str,
176    what: &'static str,
177) -> Result<Value, ParseError> {
178    let Some(v) = o.get(key) else {
179        return Ok(Value::Null);
180    };
181    crate::model::check_json_depth(v, what).map_err(ParseError::Invalid)?;
182    Ok(v.clone())
183}
184
185/// Read a wire position as a [`Usv`] index. **Checked**, not `as usize`: the
186/// deployment target is wasm32, where the truncating cast turns `2^32 + 5` into
187/// an in-range `5`, landing a mark at the wrong position instead of rejecting
188/// the document.
189pub(crate) fn usv_from(v: Option<&Value>, what: &'static str) -> Result<Usv, ParseError> {
190    let n = v.and_then(Value::as_u64).ok_or(ParseError::Shape(what))?;
191    Usv::try_from(n).map_err(|_| ParseError::Shape(what))
192}
193
194fn arr<'a>(obj: &'a Map<String, Value>, key: &'static str) -> Result<&'a Vec<Value>, ParseError> {
195    obj.get(key)
196        .and_then(Value::as_array)
197        .ok_or(ParseError::Shape(key))
198}
199
200/// `v` as a slice, empty when it is not an array: the lenient counterpart to
201/// [`arr`], since [`from_canonical_value`] owns the shape errors.
202fn as_slice(v: &Value) -> &[Value] {
203    v.as_array().map(Vec::as_slice).unwrap_or_default()
204}
205
206fn arr_or_empty<'a>(v: &'a Value, key: &str) -> &'a [Value] {
207    v.get(key).map(as_slice).unwrap_or_default()
208}
209
210// The `@0.93.0` payload keys, per built-in name: the spelling that put a
211// built-in's payload in named siblings. One frozen table, read from both sides —
212// [`payload`] falls back to exactly the keys [`reject_legacy_siblings`] refuses
213// — so a key a later promotion adds is in neither, and the two spellings stay
214// split by release rather than by which names a build knows.
215
216/// The `@0.93.0` payload keys of a built-in `kind`.
217fn legacy_line_kind_keys(tag: &str) -> &'static [&'static str] {
218    match tag {
219        "heading" => &["level"],
220        "code" => &["lang"],
221        _ => &[],
222    }
223}
224
225/// The `@0.93.0` payload keys of a built-in `container`. `instance` is not among
226/// them: it was an envelope key then and stays one.
227fn legacy_container_keys(tag: &str) -> &'static [&'static str] {
228    match tag {
229        "list_item" => &["ordered", "ordinal", "start"],
230        _ => &[],
231    }
232}
233
234/// The `@0.93.0` payload keys of a built-in mark `type`. `start`/`end` are not
235/// among them: they are the mark's own envelope.
236fn legacy_mark_keys(ty: &str) -> &'static [&'static str] {
237    match ty {
238        "link" => &["url"],
239        "anchor" => &["id"],
240        _ => &[],
241    }
242}
243
244/// One entry of a built-in's payload bag: `attrs.<key>`, or the named sibling
245/// `<key>` where there is no bag and `key` is one of `legacy` — the `@0.93.0`
246/// spelling, which storage still holds and no migration reaches.
247///
248/// Unambiguous because a bag's presence is a pure function of the value: a
249/// built-in carrying a payload always writes one, so an absent bag means either
250/// the sibling spelling or an empty payload, and those agree on every key. An
251/// empty bag is an absent one here as it is everywhere else, so the two
252/// spellings of "no payload" cannot answer differently.
253fn payload<'a>(o: &'a Map<String, Value>, legacy: &[&str], key: &'static str) -> Option<&'a Value> {
254    match o.get("attrs").filter(|a| !crate::model::is_empty_bag(a)) {
255        Some(attrs) => attrs.get(key),
256        None if legacy.contains(&key) => o.get(key),
257        None => None,
258    }
259}
260
261/// Write a payload bag into an encoder's own map, omitting an empty one:
262/// presence is a pure function of the value, the rule `continues: false`
263/// already follows.
264fn insert_attrs(m: &mut Map<String, Value>, attrs: Cow<'_, Value>) {
265    if !crate::model::is_empty_bag(&attrs) {
266        m.insert("attrs".into(), attrs.into_owned());
267    }
268}
269
270/// Encode a [`LineKind`] into its canonical `kind` object (`{"kind":"para"}`,
271/// `{"attrs":{"level":n},"kind":"heading"}`, …).
272pub fn line_kind_to_value(kind: &LineKind) -> Value {
273    Value::Object(line_kind_fields(kind))
274}
275
276/// The same fields unwrapped, for [`line_to_value`], which flattens them beside
277/// a line's own keys.
278fn line_kind_fields(kind: &LineKind) -> Map<String, Value> {
279    // One arm for the whole vocabulary: the tag *is* the discriminator and the
280    // payload rides the `attrs` bag whether or not this build knows the role, so
281    // a reader lacking it carries the line whole and the build that gains it
282    // reads what the reader wrote.
283    let mut m = Map::new();
284    insert_attrs(&mut m, kind.attrs());
285    m.insert("kind".into(), Value::String(kind.tag().to_string()));
286    m
287}
288
289/// Decode a [`LineKind`] from an object carrying the canonical `kind` fields.
290pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
291    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
292    // A missing/non-string `kind` is a shape error; a string outside the
293    // vocabulary is `UnknownName`. A non-string is not a name.
294    let tag = o
295        .get("kind")
296        .and_then(Value::as_str)
297        .ok_or(ParseError::Shape("line kind"))?;
298    let legacy = legacy_line_kind_keys(tag);
299    match tag {
300        "para" => Ok(LineKind::Para),
301        "heading" => {
302            let level = payload(o, legacy, "level")
303                .and_then(Value::as_u64)
304                .ok_or(ParseError::Shape("heading level"))?;
305            if !(1..=6).contains(&level) {
306                return Err(ParseError::Shape("heading level"));
307            }
308            Ok(LineKind::Heading { level: level as u8 })
309        }
310        "code" => Ok(LineKind::Code {
311            lang: payload(o, legacy, "lang")
312                .and_then(Value::as_str)
313                .map(crate::import::sanitize_lang)
314                .filter(|l| !l.is_empty()),
315        }),
316        "island" => Ok(LineKind::Island),
317        "rule" => Ok(LineKind::Rule),
318        other => Err(ParseError::UnknownName {
319            axis: "line kind",
320            name: other.to_string(),
321        }),
322    }
323}
324
325fn line_to_value(line: &Line) -> Value {
326    let mut m = line_kind_fields(&line.kind);
327    m.insert(
328        "containers".into(),
329        Value::Array(line.containers.iter().map(container_to_value).collect()),
330    );
331    // Omitted when false: presence is a pure function of the value, so the
332    // encoding stays deterministic.
333    if line.continues {
334        m.insert("continues".into(), Value::Bool(true));
335    }
336    // `line_kind_fields` merges `kind` in ahead of `containers`/`continues`,
337    // which sort before it, so this one encoder cannot settle its order at the
338    // insert.
339    Value::Object(sort_own_keys(m))
340}
341
342fn line_from_value(v: &Value) -> Result<Line, ParseError> {
343    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
344    let kind = line_kind_from_value(v)?;
345    let containers = o
346        .get("containers")
347        .and_then(Value::as_array)
348        .ok_or(ParseError::Shape("containers"))?
349        .iter()
350        .map(container_from_value)
351        .collect::<Result<_, _>>()?;
352    let continues = o.get("continues").and_then(Value::as_bool).unwrap_or(false);
353    Ok(Line {
354        kind,
355        containers,
356        continues,
357    })
358}
359
360/// Encode a [`Container`] into its canonical wire object. A zero `instance` is
361/// omitted, so a row written before the field existed re-encodes byte for byte;
362/// [`container_from_value`] reads an absent key as zero.
363pub fn container_to_value(c: &Container) -> Value {
364    let mut m = Map::new();
365    insert_attrs(&mut m, c.attrs());
366    m.insert("container".into(), Value::String(c.tag().to_string()));
367    // Not payload: the discriminator that keeps two adjacent same-shape runs
368    // apart is an envelope key, carried on every arm.
369    if c.instance() != 0 {
370        m.insert("instance".into(), Value::from(c.instance()));
371    }
372    Value::Object(m)
373}
374
375/// Decode a [`Container`] from its canonical wire object.
376pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
377    let o = v.as_object().ok_or(ParseError::Shape("container"))?;
378    let tag = o
379        .get("container")
380        .and_then(Value::as_str)
381        .ok_or(ParseError::Shape("container kind"))?;
382    let instance = o.get("instance").and_then(Value::as_u64).unwrap_or(0);
383    let legacy = legacy_container_keys(tag);
384    match tag {
385        "list_item" => Ok(Container::ListItem {
386            ordered: payload(o, legacy, "ordered")
387                .and_then(Value::as_bool)
388                .unwrap_or(false),
389            start: payload(o, legacy, "start")
390                .and_then(Value::as_u64)
391                .unwrap_or(1),
392            ordinal: payload(o, legacy, "ordinal")
393                .and_then(Value::as_u64)
394                .unwrap_or(0),
395            instance,
396        }),
397        "quote" => Ok(Container::Quote { instance }),
398        other => Err(ParseError::UnknownName {
399            axis: "container",
400            name: other.to_string(),
401        }),
402    }
403}
404
405/// Encode a [`Mark`] (`start`, `end`, `type`, …) into its canonical wire object.
406pub fn mark_to_value(mark: &Mark) -> Value {
407    let mut m = Map::new();
408    insert_attrs(&mut m, mark.kind.attrs());
409    m.insert("end".into(), Value::from(mark.end));
410    m.insert("start".into(), Value::from(mark.start));
411    m.insert("type".into(), Value::String(mark.kind.tag().to_string()));
412    Value::Object(m)
413}
414
415/// What every mark carries whatever its type.
416struct MarkShape<'a> {
417    fields: &'a Map<String, Value>,
418    start: Usv,
419    end: Usv,
420    ty: &'a str,
421}
422
423/// A mark's fallible half: the prologue of [`mark_from_value`], where the
424/// envelope is read and the range checked, before the `type` selects an arm.
425fn mark_shape(v: &Value) -> Result<MarkShape<'_>, ParseError> {
426    let fields = v.as_object().ok_or(ParseError::Shape("mark"))?;
427    let start = usv_from(fields.get("start"), "mark start")?;
428    let end = usv_from(fields.get("end"), "mark end")?;
429    let ty = fields
430        .get("type")
431        .and_then(Value::as_str)
432        .ok_or(ParseError::Shape("mark type"))?;
433    Ok(MarkShape {
434        fields,
435        start,
436        end,
437        ty,
438    })
439}
440
441/// Decode a [`Mark`] from its canonical wire object.
442pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
443    let MarkShape {
444        fields: o,
445        start,
446        end,
447        ty,
448    } = mark_shape(v)?;
449    let legacy = legacy_mark_keys(ty);
450    let kind = match ty {
451        "strong" => MarkKind::Strong,
452        "emph" => MarkKind::Emph,
453        "underline" => MarkKind::Underline,
454        "strike" => MarkKind::Strike,
455        "code" => MarkKind::Code,
456        "link" => MarkKind::Link {
457            url: payload(o, legacy, "url")
458                .and_then(Value::as_str)
459                .unwrap_or_default()
460                .to_string(),
461        },
462        "anchor" => MarkKind::Anchor {
463            id: payload(o, legacy, "id")
464                .and_then(Value::as_str)
465                .unwrap_or_default()
466                .to_string(),
467        },
468        other => {
469            return Err(ParseError::UnknownName {
470                axis: "mark type",
471                name: other.to_string(),
472            })
473        }
474    };
475    Ok(Mark { start, end, kind })
476}
477
478// Authored-lane readers, strict about the payload spelling.
479//
480// [`payload`] reads a built-in's payload from a named sibling where there is no
481// bag, so `{"type": "link", "url": "…"}` decodes. The two lanes answer that
482// oppositely. Storage (`Content::from_canonical_json`) stays lenient: content
483// stored in that spelling is beyond any migration's reach, a `richtext` field
484// resting as a content object under no schema tag. Authored (the `crate::ops`
485// wire, and `overwrite` through [`from_authored_value`]) rejects it, the shape
486// meaning a stale copy of the encoding.
487//
488// The rule is narrow on purpose: a *legacy payload key* beside the name that
489// spelled it, nothing else, off the same frozen table [`payload`] reads.
490
491/// [`line_kind_from_value`] for the authored lane: a legacy payload sibling, or
492/// a `lang` the storage decode would reduce, is a shape error rather than a
493/// silent repair.
494pub(crate) fn line_kind_from_authored_value(v: &Value) -> Result<LineKind, ParseError> {
495    reject_line_kind_legacy(v)?;
496    reject_unwritable_lang(v)?;
497    line_kind_from_value(v)
498}
499
500/// A `lang` [`crate::import::sanitize_lang`] would change is a shape error: the
501/// emitter writes it onto the fence header unquoted, so the storage lane's
502/// reduction of it is a repair the host did not ask for.
503fn reject_unwritable_lang(v: &Value) -> Result<(), ParseError> {
504    let Some(o) = v.as_object() else {
505        return Ok(());
506    };
507    if o.get("kind").and_then(Value::as_str) != Some("code") {
508        return Ok(());
509    }
510    let Some(lang) = payload(o, legacy_line_kind_keys("code"), "lang").and_then(Value::as_str)
511    else {
512        return Ok(());
513    };
514    if crate::import::sanitize_lang(lang) != lang {
515        return Err(ParseError::Shape("code lang"));
516    }
517    Ok(())
518}
519
520/// [`container_from_value`] for the authored lane. See
521/// [`line_kind_from_authored_value`].
522pub(crate) fn container_from_authored_value(v: &Value) -> Result<Container, ParseError> {
523    reject_container_legacy(v)?;
524    container_from_value(v)
525}
526
527/// [`mark_from_value`] for the authored lane. See [`line_kind_from_authored_value`].
528pub(crate) fn mark_from_authored_value(v: &Value) -> Result<Mark, ParseError> {
529    reject_mark_legacy(v)?;
530    mark_from_value(v)
531}
532
533/// The authored lane's verdict on a mark, without building it. Table cells are
534/// the only caller: [`parse_cell`] reads their marks leniently, so a cell mark
535/// reaches no strict decode that would raise the error on its own.
536pub(crate) fn reject_unreadable_mark(v: &Value) -> Result<(), ParseError> {
537    reject_mark_legacy(v)?;
538    reject_unwritable_link_url(v)?;
539    mark_from_value(v)?;
540    Ok(())
541}
542
543/// [`island_from_value`] for the authored lane: an image `url` the markdown
544/// projection cannot write is a shape error rather than a silent rewrite, the
545/// rule [`line_kind_from_authored_value`] carries for a code fence's `lang`.
546pub(crate) fn island_from_authored_value(v: &Value) -> Result<Island, ParseError> {
547    reject_unwritable_image_url(v)?;
548    island_from_value(v)
549}
550
551/// A link `url` [`crate::export::url_is_writable`] refuses is a shape error: the
552/// emitter writes it into the destination slot of `[…](…)`, which admits no line
553/// ending, so the export's percent-encoding of it is a rewrite the host did not
554/// ask for.
555///
556/// The rule is on the lanes that **store** a url. An op naming a mark already in
557/// the field matches on kind equality, and a url the model holds has no other
558/// spelling, so refusing it there would leave a legacy link unremovable.
559pub(crate) fn reject_unwritable_link_url(v: &Value) -> Result<(), ParseError> {
560    let Some(o) = v.as_object() else {
561        return Ok(());
562    };
563    if o.get("type").and_then(Value::as_str) != Some("link") {
564        return Ok(());
565    }
566    reject_unwritable_url(
567        payload(o, legacy_mark_keys("link"), "url").and_then(Value::as_str),
568        "link url",
569    )
570}
571
572/// [`reject_unwritable_link_url`] on an `image` island's `url` prop, which the
573/// emitter writes into the same slot.
574fn reject_unwritable_image_url(v: &Value) -> Result<(), ParseError> {
575    let image = crate::island::IslandType::Image.as_str();
576    if v.get("type").and_then(Value::as_str) != Some(image) {
577        return Ok(());
578    }
579    reject_unwritable_url(
580        v.get("props")
581            .and_then(|p| p.get("url"))
582            .and_then(Value::as_str),
583        "image url",
584    )
585}
586
587fn reject_unwritable_url(url: Option<&str>, err: &'static str) -> Result<(), ParseError> {
588    match url {
589        Some(u) if !crate::export::url_is_writable(u) => Err(ParseError::Shape(err)),
590        _ => Ok(()),
591    }
592}
593
594/// [`from_canonical_value`] for a content the **host authored just now**: the
595/// `overwrite` input, not a blob read back from storage. Same decode, plus the
596/// legacy-spelling rule on every axis the decode reads — line kinds,
597/// containers, prose marks, table-cell marks — the writability rule on the urls
598/// the projection spells, and the placement rule on a block-only island.
599pub fn from_authored_value(v: &Value) -> Result<Normalized, ParseError> {
600    authored_lane_scan(v)?;
601    let rt = Content::from_value(v)?;
602    reject_inline_block_island(&rt)?;
603    seal(rt)
604}
605
606/// A block-only island's slot sharing its line with other content: markdown
607/// writes a table as a block, so `Content::normalize` breaks the line around
608/// one, splitting a paragraph the host did not ask to split. Read off the
609/// decoded content, ahead of the mint that performs the break. The op wire
610/// refuses the same placement ([`crate::ops::ApplyError::BlockIslandNotAlone`]).
611fn reject_inline_block_island(rt: &Content) -> Result<(), ParseError> {
612    let chars: Vec<char> = rt.text.chars().collect();
613    match crate::model::inline_block_islands(&chars, &rt.islands).next() {
614        Some(_) => Err(ParseError::Shape("block island in a line's prose")),
615        None => Ok(()),
616    }
617}
618
619/// The authored-lane scan [`from_authored_value`] runs. Structural rather than a
620/// blind recursive walk: an island's `props` is opaque host payload that may
621/// legitimately contain an object spelled `{"type": "link", "url": …}`, and
622/// rejecting that would make the carrier unable to carry.
623fn authored_lane_scan(v: &Value) -> Result<(), ParseError> {
624    for line in arr_or_empty(v, "lines") {
625        reject_line_kind_legacy(line)?;
626        for c in arr_or_empty(line, "containers") {
627            reject_container_legacy(c)?;
628        }
629    }
630    // Both rules here: the strict decode `from_canonical_value` runs next reads
631    // prose marks on the storage lane, so it carries neither.
632    for m in arr_or_empty(v, "marks") {
633        reject_mark_legacy(m)?;
634        reject_unwritable_link_url(m)?;
635    }
636    // Cell marks ride the prose mark shape, so the rules follow them in, plus
637    // the readability check, since no strict decode reaches them. Dispatch goes
638    // through `IslandType`, so a new mark-carrying type is a compile error
639    // here rather than a silent skip.
640    for island in arr_or_empty(v, "islands") {
641        let ty = island.get("type").and_then(Value::as_str).unwrap_or_default();
642        match crate::island::IslandType::parse(ty) {
643            Some(crate::island::IslandType::Table) => {
644                let Some(props) = island.get("props") else {
645                    continue;
646                };
647                for cell in table_cell_values(props) {
648                    for m in arr_or_empty(cell, "marks") {
649                        reject_unreadable_mark(m)?;
650                    }
651                }
652            }
653            // No cells; the one prop the projection writes is the url.
654            Some(crate::island::IslandType::Image) => reject_unwritable_image_url(island)?,
655            // This scan reads raw JSON; an unknown type is the decode's to refuse.
656            None => {}
657        }
658    }
659    Ok(())
660}
661
662fn reject_line_kind_legacy(v: &Value) -> Result<(), ParseError> {
663    reject_legacy_siblings(v, "kind", legacy_line_kind_keys, "legacy kind payload")
664}
665
666fn reject_container_legacy(v: &Value) -> Result<(), ParseError> {
667    reject_legacy_siblings(
668        v,
669        "container",
670        legacy_container_keys,
671        "legacy container payload",
672    )
673}
674
675fn reject_mark_legacy(v: &Value) -> Result<(), ParseError> {
676    reject_legacy_siblings(v, "type", legacy_mark_keys, "legacy mark payload")
677}
678
679/// Error when `v` spells a built-in's payload the `@0.93.0` way, as a named
680/// sibling. A non-object or a missing/non-string discriminant is left to the
681/// reader that follows, which reports the shape error in its own terms.
682///
683/// The storage lane reads that spelling; a host writing it *now* holds a stale
684/// copy of the encoding, and the sibling it means is not necessarily the one
685/// [`payload`] would read — a bag beside it wins, so the write would land
686/// somewhere the host did not aim.
687fn reject_legacy_siblings(
688    v: &Value,
689    discriminant: &str,
690    legacy: fn(&str) -> &'static [&'static str],
691    err: &'static str,
692) -> Result<(), ParseError> {
693    let Some(o) = v.as_object() else {
694        return Ok(());
695    };
696    let Some(tag) = o.get(discriminant).and_then(Value::as_str) else {
697        return Ok(());
698    };
699    if legacy(tag).iter().any(|k| o.contains_key(*k)) {
700        return Err(ParseError::Shape(err));
701    }
702    Ok(())
703}
704
705/// Parse a table-cell object `{text, marks}` leniently: its plain text plus the
706/// marks over it, their ranges USV offsets into that text and their wire shape
707/// the one prose marks use. A malformed mark is skipped rather than failing.
708/// Public so the typst emitter renders a cell through the same parse the codecs
709/// use.
710pub fn parse_cell(v: &Value) -> (String, Vec<Mark>) {
711    let text = v
712        .get("text")
713        .and_then(Value::as_str)
714        .unwrap_or_default()
715        .to_string();
716    let marks = v
717        .get("marks")
718        .and_then(Value::as_array)
719        .map(|arr| arr.iter().filter_map(|m| mark_from_value(m).ok()).collect())
720        .unwrap_or_default();
721    (text, marks)
722}
723
724/// Build a table-cell object `{marks, text}`.
725pub(crate) fn cell_to_value(text: &str, marks: &[Mark]) -> Value {
726    let mut m = Map::new();
727    m.insert(
728        "marks".into(),
729        Value::Array(marks.iter().map(mark_to_value).collect()),
730    );
731    m.insert("text".into(), Value::String(text.to_string()));
732    Value::Object(m)
733}
734
735/// Every cell object in a table island's props, header then each body row: the
736/// undecoded half of [`table_cells`], walking the same cells in the same order.
737pub(crate) fn table_cell_values(props: &Value) -> impl Iterator<Item = &Value> {
738    let header = arr_or_empty(props, "header").iter();
739    let rows = arr_or_empty(props, "rows")
740        .iter()
741        .flat_map(|row| as_slice(row).iter());
742    header.chain(rows)
743}
744
745/// Every cell's `(text, marks)` in a table island's props: header then each
746/// body row, in order. For [`Content::validate`]'s cell-mark invariant checks.
747pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
748    table_cell_values(props).map(parse_cell).collect()
749}
750
751// The `table` codec below is the primitive `crate::island` dispatches into for
752// `IslandType::Table`; island-type dispatch itself lives there.
753
754/// Repair a table island's props in place to the canonical shape:
755///
756/// - **One column count.** `cols` is the widest of the header, any body row, and
757///   `aligns`; the header, each row, and `aligns` are padded up to it (padding
758///   only grows: no cell is ever truncated). Materializing the count into the
759///   header means the markdown projection (header-derived) and the Typst
760///   projection (widest-row) agree on one number.
761/// - **Single-line cells.** Any `\n`/`\r` in a cell's text becomes a space (the
762///   same rule import applies to soft/hard breaks). A 1:1 replacement keeps char
763///   offsets stable, so the cell's marks stay in range.
764/// - **Canonical cell marks.** Each cell's marks are re-normalized (sort,
765///   same-kind union, drop zero-width) so equal cells serialize to equal bytes.
766/// - **Arrays where arrays belong.** A present non-array `header`, `aligns`, or
767///   row carries no cells, so it becomes an empty array.
768pub(crate) fn normalize_table_props(props: &mut Value) {
769    let cols = table_cols(props);
770    let Some(obj) = props.as_object_mut() else {
771        return;
772    };
773    let header = obj.entry("header").or_insert_with(|| Value::Array(vec![]));
774    if !header.is_array() {
775        *header = Value::Array(vec![]);
776    }
777    pad_row(header, cols);
778    if let Some(h) = header.as_array_mut() {
779        h.iter_mut().for_each(canon_cell);
780    }
781    let aligns = obj.entry("aligns").or_insert_with(|| Value::Array(vec![]));
782    if !aligns.is_array() {
783        *aligns = Value::Array(vec![]);
784    }
785    if let Some(a) = aligns.as_array_mut() {
786        while a.len() < cols {
787            a.push(Value::String("none".into()));
788        }
789    }
790    if let Some(rows) = obj.get_mut("rows").and_then(Value::as_array_mut) {
791        for row in rows.iter_mut() {
792            if !row.is_array() {
793                *row = Value::Array(vec![]);
794            }
795            pad_row(row, cols);
796            if let Some(r) = row.as_array_mut() {
797                r.iter_mut().for_each(canon_cell);
798            }
799        }
800    }
801}
802
803/// A table's canonical column count: the widest of its header, any body row, and
804/// its `aligns` array. Padding (never truncation) brings every part up to it.
805fn table_cols(props: &Value) -> usize {
806    let arr_len = |k: &str| props.get(k).and_then(Value::as_array).map(|a| a.len());
807    let header = arr_len("header").unwrap_or(0);
808    let aligns = arr_len("aligns").unwrap_or(0);
809    let widest_row = props
810        .get("rows")
811        .and_then(Value::as_array)
812        .map(|rows| {
813            rows.iter()
814                .map(|r| r.as_array().map(|a| a.len()).unwrap_or(0))
815                .max()
816                .unwrap_or(0)
817        })
818        .unwrap_or(0);
819    header.max(aligns).max(widest_row)
820}
821
822/// Pad a cell array (header or body row) up to `cols` with empty cells. Never
823/// shrinks: `cols` is the widest, so a shorter array only grows.
824fn pad_row(v: &mut Value, cols: usize) {
825    if let Some(arr) = v.as_array_mut() {
826        while arr.len() < cols {
827            arr.push(cell_to_value("", &[]));
828        }
829    }
830}
831
832/// Every char a downstream lexer reads as a line break, the separators
833/// [`crate::normalize::is_line_separator`] names included. A cell is one line.
834fn is_cell_break(c: char) -> bool {
835    c == '\n' || c == '\r' || crate::normalize::is_line_separator(c)
836}
837
838/// De-newline a cell's text (each line break → a space, 1:1 so mark offsets
839/// hold) and re-normalize its marks. Writes back into the cell's **own** object
840/// rather than minting a fresh one, so a key this build does not recognize
841/// survives.
842fn canon_cell(cell: &mut Value) {
843    let (text, marks) = parse_cell(cell);
844    let text = if text.contains(is_cell_break) {
845        text.replace(is_cell_break, " ")
846    } else {
847        text
848    };
849    let canon = cell_to_value(&text, &crate::model::normalize_marks(marks));
850    match (cell.as_object_mut(), canon) {
851        // Overwrite the canonical keys, leave the rest.
852        (Some(o), Value::Object(fields)) => o.extend(fields),
853        // A non-object cell holds no keys to preserve.
854        (_, canon) => *cell = canon,
855    }
856}
857
858pub(crate) fn island_to_value(island: &Island) -> Value {
859    let mut m = Map::new();
860    m.insert("id".into(), Value::String(island.id.clone()));
861    m.insert("loss".into(), island.loss.as_str().into());
862    m.insert("props".into(), island.props.clone());
863    m.insert("type".into(), Value::String(island.island_type.as_str().into()));
864    Value::Object(m)
865}
866
867pub(crate) fn island_from_value(v: &Value) -> Result<Island, ParseError> {
868    let o = v.as_object().ok_or(ParseError::Shape("island"))?;
869    let name = o
870        .get("type")
871        .and_then(Value::as_str)
872        .ok_or(ParseError::Shape("island type"))?;
873    let island_type =
874        crate::island::IslandType::parse(name).ok_or_else(|| ParseError::UnknownName {
875            axis: "island type",
876            name: name.to_string(),
877        })?;
878    let props = bag_from_wire(o, "props", "island props")?;
879    island_type.reject_unknown_cell_mark(&props)?;
880    Ok(Island {
881        id: o
882            .get("id")
883            .and_then(Value::as_str)
884            .ok_or(ParseError::Shape("island id"))?
885            .to_string(),
886        island_type,
887        props,
888        // A missing key is the faithful class: it predates the key.
889        loss: match o.get("loss") {
890            None => Loss::Lossless,
891            Some(Value::String(name)) => Loss::parse(name)
892                .ok_or_else(|| ParseError::UnknownName {
893                    axis: "island loss",
894                    name: name.clone(),
895                })?,
896            Some(_) => return Err(ParseError::Shape("island loss")),
897        },
898    })
899}
900
901/// The mark vocabulary's verdict on a table island's cells, for
902/// [`crate::island::IslandType::reject_unknown_cell_mark`].
903///
904/// A cell mark reaches no other strict decode: [`parse_cell`] reads them
905/// leniently and `canon_cell` writes the survivors back, so a name outside the
906/// vocabulary would leave the stored bytes on a read with no edit rather than
907/// refusing the row. A *malformed* cell mark stays skipped — that is the split
908/// canon § "an unreadable table-cell mark" sets, and it is about shape, not
909/// names.
910pub(crate) fn reject_unknown_cell_mark_name(props: &Value) -> Result<(), ParseError> {
911    for cell in table_cell_values(props) {
912        for m in arr_or_empty(cell, "marks") {
913            if let Err(e @ ParseError::UnknownName { .. }) = mark_from_value(m) {
914                return Err(e);
915            }
916        }
917    }
918    Ok(())
919}
920
921#[cfg(test)]
922mod tests {
923
924    /// `instance` is written only where it is doing work, so it costs bytes only
925    /// in the documents carrying an adjacent same-shape sibling. A spelled zero
926    /// still decodes, which is what lets a producer holding an older read write
927    /// it straight back.
928    #[test]
929    fn instance_is_written_only_where_it_works_and_a_spelled_zero_still_decodes() {
930        let canonical = r#"{"islands":[],"lines":[{"containers":[{"container":"quote"}],"kind":"para"}],"marks":[],"text":"a"}"#;
931        let spelled = r#"{"islands":[],"lines":[{"containers":[{"container":"quote","instance":0}],"kind":"para"}],"marks":[],"text":"a"}"#;
932        let rt = Content::from_canonical_json(canonical).expect("decodes");
933        assert_eq!(rt.to_canonical_json(), canonical);
934        assert_eq!(Content::from_canonical_json(spelled).expect("decodes"), rt);
935
936        // Two adjacent one-item lists: the shape that spends the key.
937        let two = r#"{"islands":[],"lines":[{"containers":[{"attrs":{"ordered":false,"ordinal":0,"start":1},"container":"list_item"}],"kind":"para"},{"containers":[{"attrs":{"ordered":false,"ordinal":0,"start":1},"container":"list_item","instance":1}],"kind":"para"}],"marks":[],"text":"a\nb"}"#;
938        let rt = Content::from_canonical_json(two).expect("decodes");
939        assert_eq!(rt.to_canonical_json(), two, "byte layout moved");
940    }
941
942    use super::*;
943    use crate::island::IslandType;
944    use crate::model::{Invariant, Line, LineKind, Loss};
945
946    fn sample() -> Content {
947        Content {
948            text: "hello world".into(),
949            lines: vec![Line {
950                kind: LineKind::Para,
951                containers: vec![],
952                continues: false,
953            }],
954            marks: vec![
955                Mark {
956                    start: 6,
957                    end: 11,
958                    kind: MarkKind::Strong,
959                },
960                Mark {
961                    start: 0,
962                    end: 5,
963                    kind: MarkKind::Emph,
964                },
965            ],
966            islands: vec![],
967        }
968    }
969
970    /// Export recurses one frame per container, so a 20 000-deep path that
971    /// decoded clean would abort the process on `to_markdown`.
972    #[test]
973    fn deep_container_nesting_is_rejected_at_decode() {
974        let containers = vec![r#"{"container":"quote"}"#; 20_000].join(",");
975        let json = format!(
976            r#"{{"text":"hi","lines":[{{"kind":"para","containers":[{containers}]}}],"marks":[],"islands":[]}}"#
977        );
978        assert!(matches!(
979            Content::from_canonical_json(&json),
980            Err(ParseError::Invalid(Invariant::NestingTooDeep { .. }))
981        ));
982    }
983
984    /// Build a `Value` nesting `depth` array levels, iteratively so *building*
985    /// the fixture cannot overflow. Handling it still can (`Value`'s `Clone` and
986    /// `Drop` both recurse), so the tests below probe just past the cap rather
987    /// than at a depth that overflows the test itself.
988    fn nested_arrays(depth: usize) -> Value {
989        let mut v = Value::Null;
990        for _ in 0..depth {
991            v = Value::Array(vec![v]);
992        }
993        v
994    }
995
996    /// The string lane is bounded by its parser (`serde_json::from_str` refuses
997    /// past 128); the `Value` lane is the host-authored one and has to refuse
998    /// the same shape, since an unguarded deep `props` aborts the process.
999    #[test]
1000    fn deep_json_payload_is_rejected_at_decode_on_the_value_lane() {
1001        let deep = nested_arrays(1_000);
1002        let cases: [(Value, &'static str); 1] = [(
1003            serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
1004              "marks":[],"islands":[{"id":"i1","type":"image","loss":"lossless","props":deep}]}),
1005            "island props",
1006        )];
1007        for (v, what) in cases {
1008            assert_eq!(
1009                from_canonical_value(&v),
1010                Err(ParseError::Invalid(Invariant::JsonTooDeep {
1011                    what,
1012                    max: crate::MAX_JSON_DEPTH,
1013                })),
1014                "{what} accepted a 1 000-deep payload"
1015            );
1016            // The authored lane funnels through the same decode, so it refuses
1017            // the same shape rather than trapping on its own scan first.
1018            assert!(matches!(
1019                from_authored_value(&v),
1020                Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1021            ));
1022        }
1023    }
1024
1025    /// The cap admits every payload a stored blob can carry, so closing the
1026    /// `Value` lane costs no stored population. Stated as the implication
1027    /// rather than an offset, `serde_json::from_str`'s own limit counting
1028    /// from the document root rather than from the bag.
1029    #[test]
1030    fn json_depth_cap_admits_every_storable_payload() {
1031        let content = |props: Value| {
1032            serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
1033              "marks":[],"islands":[{"id":"i1","type":"image","loss":"lossless","props":props}]})
1034        };
1035        assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH))).is_ok());
1036        assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH + 1))).is_err());
1037
1038        // Across the whole boundary region, string-lane-accepted implies
1039        // `Value`-lane-accepted. The converse does not hold and need not: the
1040        // string lane's root-relative count refuses a few depths the bag cap
1041        // allows.
1042        let mut storable = 0;
1043        for d in 1..=crate::MAX_JSON_DEPTH + 8 {
1044            let v = content(nested_arrays(d));
1045            if Content::from_canonical_json(&v.to_string()).is_ok() {
1046                storable = d;
1047                assert!(
1048                    from_canonical_value(&v).is_ok(),
1049                    "the bag cap refused a {d}-deep props the string lane accepts"
1050                );
1051            }
1052        }
1053        assert!(
1054            storable > 0 && storable <= crate::MAX_JSON_DEPTH,
1055            "string lane's deepest storable props was {storable}"
1056        );
1057    }
1058
1059    /// The depth cap guards what the decode **retains**. A bag beside a built-in
1060    /// that carries no payload is read by nobody and cloned by nobody, so it
1061    /// costs no frames and needs no verdict: it drops with the caller's own
1062    /// `Value`, which spends the frames it was always going to spend.
1063    #[test]
1064    fn a_deep_bag_no_arm_reads_is_dropped_rather_than_refused() {
1065        let mut deep = Value::Null;
1066        for _ in 0..1_000 {
1067            deep = serde_json::json!({"a": deep});
1068        }
1069        let line = |kind: &str| {
1070            serde_json::json!({"text":"x","lines":[{"kind":kind,"containers":[],"attrs":deep}],
1071              "marks":[],"islands":[]})
1072        };
1073        // `para` carries no payload: the bag is foreign, and drops unread.
1074        assert!(from_canonical_value(&line("para")).is_ok());
1075    }
1076
1077    #[test]
1078    fn deep_json_payload_is_rejected_on_the_op_wire() {
1079        let deep = nested_arrays(1_000);
1080        // An island's `props` is the one payload the op wire retains.
1081        let op = serde_json::json!({"op":"insert","at":0,
1082          "id":"i1","type":"image","loss":"lossless","props":deep});
1083        assert!(matches!(
1084            crate::ops::island_op_from_value(&op),
1085            Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1086        ));
1087    }
1088
1089    /// A wire position past `usize` is refused, not truncated: on wasm32 the
1090    /// truncating cast lands a mark at the wrong position in a document that
1091    /// then validates clean. Refused by the checked read on 32-bit and by the
1092    /// range invariant on 64-bit.
1093    #[test]
1094    fn out_of_range_wire_position_is_refused() {
1095        let json = r#"{"text":"hello","lines":[{"kind":"para","containers":[]}],"marks":[{"start":4294967301,"end":4294967302,"type":"strong"}],"islands":[]}"#;
1096        assert!(Content::from_canonical_json(json).is_err());
1097        assert!(usv_from(Some(&Value::from(u64::MAX)), "x").is_ok() || usize::BITS < 64);
1098        assert!(usv_from(Some(&Value::from(-1i64)), "x").is_err());
1099    }
1100
1101    #[test]
1102    fn island_props_key_order_does_not_leak() {
1103        let mut one = Content::empty();
1104        one.text = "\u{FFFC}".into();
1105        one.lines = vec![Line {
1106            kind: LineKind::Island,
1107            containers: vec![],
1108            continues: false,
1109        }];
1110        one.islands = vec![Island {
1111            id: "i1".into(),
1112            island_type: IslandType::Table,
1113            props: serde_json::json!({"b": 1, "a": 2}),
1114            loss: Loss::Lossless,
1115        }];
1116        let mut two = one.clone();
1117        two.islands[0].props = serde_json::json!({"a": 2, "b": 1}); // keys reversed
1118        assert_eq!(
1119            one.into_normalized().to_canonical_json(),
1120            two.into_normalized().to_canonical_json()
1121        );
1122    }
1123
1124    /// Every encoder emits its own keys in ascending order, so
1125    /// [`to_canonical_value`]'s backstop scans and returns instead of rebuilding
1126    /// the tree. A key inserted out of order still serializes canonically — the
1127    /// backstop repairs it — so nothing else notices the regression.
1128    #[test]
1129    fn encoders_emit_keys_in_ascending_order() {
1130        use crate::model::is_value_key_sorted;
1131        let bag = || serde_json::json!({"a": 1, "b": 2});
1132        let sorted = |v: &Value| is_value_key_sorted(v);
1133
1134        let kinds = [
1135            MarkKind::Strong,
1136            MarkKind::Emph,
1137            MarkKind::Underline,
1138            MarkKind::Strike,
1139            MarkKind::Code,
1140            MarkKind::Link { url: "u".into() },
1141            MarkKind::Anchor { id: "a".into() },
1142        ];
1143        let marks: Vec<Mark> = kinds
1144            .iter()
1145            .map(|kind| Mark {
1146                start: 0,
1147                end: 1,
1148                kind: kind.clone(),
1149            })
1150            .collect();
1151        for m in &marks {
1152            assert!(sorted(&mark_to_value(m)), "mark {:?}", m.kind);
1153        }
1154        assert!(sorted(&cell_to_value("t", &marks)));
1155
1156        let containers = vec![
1157            Container::ListItem {
1158                ordered: true,
1159                start: 3,
1160                ordinal: 1,
1161                instance: 0,
1162            },
1163            Container::Quote { instance: 0 },
1164        ];
1165        for c in &containers {
1166            assert!(sorted(&container_to_value(c)), "container {c:?}");
1167        }
1168
1169        let line_kinds = [
1170            LineKind::Para,
1171            LineKind::Heading { level: 2 },
1172            LineKind::Code {
1173                lang: Some("rust".into()),
1174            },
1175            LineKind::Code { lang: None },
1176            LineKind::Island,
1177            LineKind::Rule,
1178        ];
1179        for kind in line_kinds {
1180            for continues in [false, true] {
1181                let line = Line {
1182                    kind: kind.clone(),
1183                    containers: containers.clone(),
1184                    continues,
1185                };
1186                assert!(sorted(&line_to_value(&line)), "line {:?}", line.kind);
1187            }
1188        }
1189
1190        for &island_type in IslandType::ALL {
1191            let island = Island {
1192                id: "i1".into(),
1193                island_type,
1194                props: bag(),
1195                loss: Loss::Lossless,
1196            };
1197            assert!(
1198                sorted(&island_to_value(&island)),
1199                "island {}",
1200                island_type.as_str()
1201            );
1202        }
1203    }
1204
1205        #[test]
1206    fn the_canonical_tree_needs_no_repair() {
1207        use crate::model::is_value_key_sorted;
1208        let mut rt = Content::empty();
1209        rt.text = "hi\n\u{FFFC}".into();
1210        rt.lines = vec![
1211            Line {
1212                kind: LineKind::Heading { level: 2 },
1213                containers: vec![Container::Quote { instance: 0 }],
1214                continues: false,
1215            },
1216            Line {
1217                kind: LineKind::Island,
1218                containers: vec![],
1219                continues: false,
1220            },
1221        ];
1222        rt.marks = vec![Mark {
1223            start: 0,
1224            end: 2,
1225            kind: MarkKind::Link { url: "u".into() },
1226        }];
1227        rt.islands = vec![Island {
1228            id: "i1".into(),
1229            island_type: IslandType::Table,
1230            props: serde_json::json!({
1231                "header": [{"text": "h", "marks": [{"start": 0, "end": 1, "type": "emph"}]}],
1232                "rows": [[{"text": "r", "marks": []}]],
1233                "aligns": ["none"],
1234            }),
1235            loss: Loss::Lossless,
1236        }];
1237        rt.normalize();
1238        assert_eq!(rt.validate(), Ok(()));
1239        assert!(is_value_key_sorted(&rt.to_value()));
1240    }
1241
1242    #[test]
1243    fn golden_bytes_are_feature_independent() {
1244        // If either string changes, the freeze changed: bump the schema version.
1245        let rt = sample().into_normalized();
1246        assert_eq!(
1247            rt.to_canonical_json(),
1248            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":5,"start":0,"type":"emph"},{"end":11,"start":6,"type":"strong"}],"text":"hello world"}"#
1249        );
1250
1251        // A payload on all three carrier axes. The sample above spends none, so
1252        // on its own it sleeps through a change to *how* a payload is spelled —
1253        // which is the change a schema bump is most likely to be.
1254        let mut rt = Content::empty();
1255        rt.text = "hi".into();
1256        rt.lines = vec![Line {
1257            kind: LineKind::Heading { level: 2 },
1258            containers: vec![Container::ListItem {
1259                ordered: true,
1260                start: 3,
1261                ordinal: 0,
1262                instance: 0,
1263            }],
1264            continues: false,
1265        }];
1266        rt.marks = vec![Mark {
1267            start: 0,
1268            end: 2,
1269            kind: MarkKind::Link { url: "u".into() },
1270        }];
1271        assert_eq!(
1272            rt.into_normalized().to_canonical_json(),
1273            concat!(
1274                r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":"#,
1275                r#"[{"attrs":{"ordered":true,"ordinal":0,"start":3},"container":"list_item"}],"#,
1276                r#""kind":"heading"}],"marks":[{"attrs":{"url":"u"},"end":2,"start":0,"type":"link"}],"#,
1277                r#""text":"hi"}"#
1278            )
1279        );
1280    }
1281
1282    #[test]
1283    fn from_canonical_json_rejects_invalid() {
1284        // lines.len() != segment count.
1285        let bad =
1286            r#"{"text":"a\nb","lines":[{"kind":"para","containers":[]}],"marks":[],"islands":[]}"#;
1287        assert!(matches!(
1288            Content::from_canonical_json(bad),
1289            Err(ParseError::Invalid(_))
1290        ));
1291    }
1292
1293    /// Every axis is a closed set, and the two lanes agree: the name is
1294    /// refused, not carried, and the error names the axis and the name.
1295    #[test]
1296    fn an_unknown_name_is_refused_on_every_axis_and_both_lanes() {
1297        let doc = |islands: &str, lines: &str, marks: &str, text: &str| {
1298            format!(r#"{{"islands":[{islands}],"lines":[{lines}],"marks":[{marks}],"text":"{text}"}}"#)
1299        };
1300        let cases = [
1301            (
1302                "line kind",
1303                "callout",
1304                doc("", r#"{"containers":[],"kind":"callout"}"#, "", "hi"),
1305            ),
1306            (
1307                "container",
1308                "indent",
1309                doc(
1310                    "",
1311                    r#"{"containers":[{"container":"indent","instance":0}],"kind":"para"}"#,
1312                    "",
1313                    "hi",
1314                ),
1315            ),
1316            (
1317                "mark type",
1318                "highlight",
1319                doc(
1320                    "",
1321                    r#"{"containers":[],"kind":"para"}"#,
1322                    r#"{"end":2,"start":0,"type":"highlight"}"#,
1323                    "hi",
1324                ),
1325            ),
1326            (
1327                "island type",
1328                "widget",
1329                doc(
1330                    r#"{"id":"i1","loss":"lossless","props":{},"type":"widget"}"#,
1331                    r#"{"containers":[],"kind":"island"}"#,
1332                    "",
1333                    "\u{fffc}",
1334                ),
1335            ),
1336            (
1337                "island loss",
1338                "partial",
1339                doc(
1340                    r#"{"id":"i1","loss":"partial","props":{},"type":"table"}"#,
1341                    r#"{"containers":[],"kind":"island"}"#,
1342                    "",
1343                    "\u{fffc}",
1344                ),
1345            ),
1346        ];
1347        for (axis, name, json) in cases {
1348            let v: Value = serde_json::from_str(&json).unwrap();
1349            for (lane, got) in [
1350                ("storage", from_canonical_value(&v)),
1351                ("authored", from_authored_value(&v)),
1352            ] {
1353                assert_eq!(
1354                    got.unwrap_err(),
1355                    ParseError::UnknownName {
1356                        axis,
1357                        name: name.to_string()
1358                    },
1359                    "{lane} lane accepted {axis} {name:?}"
1360                );
1361            }
1362        }
1363    }
1364
1365    /// A table cell's marks are read leniently, so without a decoder arm of
1366    /// their own a name outside the vocabulary would be dropped by `canon_cell`
1367    /// and the row would open with its bytes moved. It is refused instead, on
1368    /// both lanes, like the mark axis everywhere else.
1369    #[test]
1370    fn an_unknown_cell_mark_name_is_refused_rather_than_dropped() {
1371        let cell_marks = |marks: &str| {
1372            format!(
1373                concat!(
1374                    r#"{{"islands":[{{"id":"i1","loss":"lossless","props":{{"aligns":["none"],"#,
1375                    r#""header":[{{"marks":[{marks}],"text":"h"}}],"#,
1376                    r#""rows":[[{{"marks":[],"text":"c"}}]]}},"type":"table"}}],"#,
1377                    "\"lines\":[{{\"containers\":[],\"kind\":\"island\"}}],\"marks\":[],\"text\":\"\u{fffc}\"}}"
1378                ),
1379                marks = marks
1380            )
1381        };
1382        let outside = cell_marks(r#"{"end":1,"start":0,"type":"highlight"}"#);
1383        let v: Value = serde_json::from_str(&outside).unwrap();
1384        for (lane, got) in [
1385            ("storage", from_canonical_value(&v)),
1386            ("authored", from_authored_value(&v)),
1387        ] {
1388            assert_eq!(
1389                got.unwrap_err(),
1390                ParseError::UnknownName {
1391                    axis: "mark type",
1392                    name: "highlight".to_string()
1393                },
1394                "{lane} lane accepted a cell mark type outside the vocabulary"
1395            );
1396        }
1397
1398        // The split holds: a *malformed* cell mark is still skipped, so the row
1399        // opens. That rule is about shape, and predates the closure.
1400        let malformed = cell_marks(r#"{"end":"x","start":0,"type":"strong"}"#);
1401        let v: Value = serde_json::from_str(&malformed).unwrap();
1402        assert!(from_canonical_value(&v).is_ok());
1403    }
1404
1405    /// A malformed discriminator is a shape error, not a vocabulary one: the
1406    /// closed set answers for names, and a non-string is not a name.
1407    #[test]
1408    fn a_malformed_discriminator_is_a_shape_error() {
1409        for bad in [
1410            r#"{"islands":[],"lines":[{"containers":[]}],"marks":[],"text":"x"}"#,
1411            r#"{"islands":[],"lines":[{"containers":[{"container":7}],"kind":"para"}],"marks":[],"text":"x"}"#,
1412            "{\"islands\":[{\"id\":\"i\",\"loss\":7,\"props\":{},\"type\":\"table\"}],\"lines\":[{\"containers\":[],\"kind\":\"island\"}],\"marks\":[],\"text\":\"\u{fffc}\"}",
1413        ] {
1414            assert!(
1415                matches!(
1416                    Content::from_canonical_json(bad),
1417                    Err(ParseError::Shape(_))
1418                ),
1419                "not a shape error: {bad}"
1420            );
1421        }
1422    }
1423
1424    /// So the closed view and the wire spellings cannot drift apart.
1425    #[test]
1426    fn every_fidelity_level_round_trips_through_its_class() {
1427        for &l in Loss::ALL {
1428            assert_eq!(Loss::parse(l.as_str()), Some(l));
1429        }
1430    }
1431
1432    /// A host writing the `@0.93.0` spelling now holds a stale copy of the
1433    /// encoding, and where a bag sits beside it the sibling it meant is not
1434    /// the one [`payload`] reads. The last case is a cell mark that will not
1435    /// parse at all, the one axis with no strict decode behind it.
1436    #[test]
1437    fn authored_lane_rejects_the_legacy_payload_spelling() {
1438        let bad = [
1439            // line kind
1440            r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"x"}"#,
1441            // container
1442            r#"{"islands":[],"lines":[{"containers":[{"container":"list_item","ordered":true}],"kind":"para"}],"marks":[],"text":"x"}"#,
1443            // prose mark
1444            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":1,"start":0,"type":"link","url":"u"}],"text":"x"}"#,
1445            // table cell mark
1446            concat!(
1447                r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
1448                r#""header":[{"marks":[{"end":1,"start":0,"type":"link","url":"u"}],"text":"h"}],"#,
1449                r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
1450                r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1451            ),
1452            // table cell mark with no `type` at all
1453            concat!(
1454                r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
1455                r#""header":[{"marks":[{"end":1,"start":0}],"text":"h"}],"#,
1456                r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
1457                r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1458            ),
1459        ];
1460        for json in bad {
1461            let v: Value = serde_json::from_str(json).unwrap();
1462            assert!(
1463                matches!(from_authored_value(&v), Err(ParseError::Shape(_))),
1464                "accepted: {json}"
1465            );
1466            // The storage lane opens all five: stored content in that
1467            // spelling must keep loading, and most of it reaches no migration.
1468            assert!(
1469                Content::from_canonical_json(json).is_ok(),
1470                "storage lane rejected: {json}"
1471            );
1472        }
1473        // …and what storage does with the unreadable one: skips it, keeping the
1474        // document openable.
1475        let rt = Content::from_canonical_json(bad[4]).unwrap();
1476        assert!(rt.islands[0].props["header"][0]["marks"]
1477            .as_array()
1478            .unwrap()
1479            .is_empty());
1480    }
1481
1482    /// A foreign bag on a built-in is neither the legacy spelling nor a
1483    /// payload: it drops unread on a member that carries none, and is read past
1484    /// on one that does. Both lanes agree, the shape being unambiguous.
1485    #[test]
1486    fn a_foreign_bag_on_a_built_in_drops_unread() {
1487        let json = concat!(
1488            r#"{"islands":[],"lines":[{"attrs":{"tone":"warn"},"containers":"#,
1489            r#"[{"attrs":{"x":1},"container":"quote"}],"kind":"para"}],"#,
1490            r#""marks":[{"attrs":{"y":2},"end":1,"start":0,"type":"strong"}],"text":"x"}"#
1491        );
1492        let v: Value = serde_json::from_str(json).unwrap();
1493        let rt = from_authored_value(&v).expect("authored lane accepts");
1494        assert_eq!(rt.lines[0].kind, LineKind::Para);
1495        assert_eq!(rt.marks[0].kind, MarkKind::Strong);
1496        assert_eq!(
1497            Content::from_canonical_json(json).expect("storage lane accepts"),
1498            rt
1499        );
1500        assert_eq!(
1501            rt.to_canonical_json(),
1502            r#"{"islands":[],"lines":[{"containers":[{"container":"quote"}],"kind":"para"}],"marks":[{"end":1,"start":0,"type":"strong"}],"text":"x"}"#
1503        );
1504    }
1505
1506    /// An opaque carrier is not scanned: a foreign key inside a table island's
1507    /// `props` may hold a link-shaped value with a url the projection could not
1508    /// write, and the authored lane leaves it alone.
1509    #[test]
1510    fn authored_lane_leaves_opaque_props_payload_alone() {
1511        let json = concat!(
1512            r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
1513            r#""header":[{"marks":[],"text":"h"}],"note":{"type":"link","url":"a\nb"},"#,
1514            r#""rows":[[{"marks":[],"text":"c"}]]},"type":"table"}],"#,
1515            r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
1516        );
1517        let v: Value = serde_json::from_str(json).unwrap();
1518        let rt = from_authored_value(&v).unwrap();
1519        assert_eq!(rt.to_canonical_json(), json);
1520    }
1521
1522    /// A `lang` is written into a fence header unquoted, so every lane that
1523    /// mints a `Code` reduces it to the identifier shape the emitter assumes —
1524    /// the storage decode and the `setKind` op wire as much as the importer.
1525    #[test]
1526    fn decoded_code_lang_carries_the_sanitized_shape() {
1527        let cases: [(Value, Option<&str>); 5] = [
1528            (
1529                serde_json::json!({"kind": "code", "attrs": {"lang": "rust\ninjected line"}}),
1530                Some("rust"),
1531            ),
1532            (
1533                serde_json::json!({"kind": "code", "attrs": {"lang": "r`s"}}),
1534                Some("r"),
1535            ),
1536            (
1537                serde_json::json!({"kind": "code", "attrs": {"lang": " rust"}}),
1538                None,
1539            ),
1540            (
1541                serde_json::json!({"kind": "code", "attrs": {"lang": "c++ 17"}}),
1542                Some("c++"),
1543            ),
1544            // The sibling spelling reaches the same reduction.
1545            (
1546                serde_json::json!({"kind": "code", "lang": "r`s"}),
1547                Some("r"),
1548            ),
1549        ];
1550        for (v, want) in cases {
1551            assert_eq!(
1552                line_kind_from_value(&v).unwrap(),
1553                LineKind::Code {
1554                    lang: want.map(str::to_string)
1555                },
1556                "{v}"
1557            );
1558        }
1559    }
1560
1561    /// The two lanes answer a `lang` the emitter cannot write oppositely: the
1562    /// storage decode reduces it so the blob still opens, the authored wire
1563    /// refuses it so the host hears about it.
1564    #[test]
1565    fn an_unwritable_code_lang_sanitizes_on_storage_and_is_refused_when_authored() {
1566        let v = serde_json::json!({"kind": "code", "attrs": {"lang": "rust\ninjected line"}});
1567        assert_eq!(
1568            line_kind_from_value(&v).unwrap(),
1569            LineKind::Code {
1570                lang: Some("rust".to_string())
1571            }
1572        );
1573        assert!(matches!(
1574            line_kind_from_authored_value(&v),
1575            Err(ParseError::Shape("code lang"))
1576        ));
1577    }
1578
1579    /// The same split on a link or image `url`: CommonMark admits no line
1580    /// ending in a destination, so a lane that stores one refuses it, while
1581    /// storage keeps what it holds and [`crate::export::emit_url`]
1582    /// percent-encodes it.
1583    #[test]
1584    fn an_unwritable_url_decodes_on_storage_and_is_refused_when_authored() {
1585        let mark =
1586            serde_json::json!({"type": "link", "start": 0, "end": 1, "attrs": {"url": "a\nb"}});
1587        assert_eq!(
1588            mark_from_value(&mark).unwrap().kind,
1589            MarkKind::Link { url: "a\nb".into() }
1590        );
1591        assert!(matches!(
1592            reject_unwritable_link_url(&mark),
1593            Err(ParseError::Shape("link url"))
1594        ));
1595
1596        let island =
1597            serde_json::json!({"id": "i1", "type": "image", "props": {"alt": "a", "url": "u\rv"}});
1598        assert_eq!(island_from_value(&island).unwrap().props["url"], "u\rv");
1599        assert!(matches!(
1600            island_from_authored_value(&island),
1601            Err(ParseError::Shape("image url"))
1602        ));
1603    }
1604
1605    /// The whole-content authored door (`overwrite`) carries the url rule as
1606    /// deep as the values the projection writes.
1607    #[test]
1608    fn the_authored_content_door_refuses_an_unwritable_url() {
1609        for json in [
1610            concat!(
1611                r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"#,
1612                r#""marks":[{"attrs":{"url":"a\nb"},"end":1,"start":0,"type":"link"}],"text":"x"}"#
1613            ),
1614            concat!(
1615                r#"{"islands":[{"id":"i1","loss":"lossless","props":{"alt":"a","url":"u\nv"},"#,
1616                r#""type":"image"}],"lines":[{"containers":[],"kind":"para"}],"marks":[],"text":""}"#
1617            ),
1618        ] {
1619            let v: Value = serde_json::from_str(json).unwrap();
1620            assert!(
1621                matches!(from_authored_value(&v), Err(ParseError::Shape(_))),
1622                "accepted: {json}"
1623            );
1624            assert!(
1625                Content::from_canonical_json(json).is_ok(),
1626                "storage lane rejected: {json}"
1627            );
1628        }
1629    }
1630
1631    /// The same split on a block-only island's placement. A table's markdown is
1632    /// a block, so the storage lane takes the shape a blob holds and the mint
1633    /// gives the slot the line its markup needs, while a host authoring that
1634    /// placement is told, rather than having its paragraph split for it. An
1635    /// inline island keeps its position on both lanes.
1636    #[test]
1637    fn an_inline_block_island_splits_on_storage_and_is_refused_when_authored() {
1638        let slot = crate::model::ISLAND_SLOT;
1639        let content = |island_type: &str, props: &str| {
1640            format!(
1641                concat!(
1642                    r#"{{"islands":[{{"id":"isl-0","loss":"lossless","props":{},"#,
1643                    r#""type":"{}"}}],"lines":[{{"containers":[],"kind":"para"}}],"#,
1644                    r#""marks":[],"text":"ab"}}"#
1645                ),
1646                props, island_type
1647            )
1648        };
1649        let table = content(
1650            "table",
1651            r#"{"aligns":["none"],"header":[{"marks":[],"text":"h"}],"rows":[[{"marks":[],"text":"c"}]]}"#,
1652        );
1653        let rt = Content::from_canonical_json(&table).expect("storage lane accepts");
1654        assert_eq!(rt.text, format!("a\n{slot}\nb"), "not split at the load");
1655        assert_eq!(rt.lines[1].kind, LineKind::Island);
1656        let v: Value = serde_json::from_str(&table).unwrap();
1657        assert!(matches!(from_authored_value(&v), Err(ParseError::Shape(_))));
1658
1659        let image = content("image", r#"{"alt":"a","url":"u"}"#);
1660        let rt = Content::from_canonical_json(&image).expect("storage lane accepts");
1661        assert_eq!(rt.text, format!("a{slot}b"), "inline island moved");
1662        let v: Value = serde_json::from_str(&image).unwrap();
1663        assert!(from_authored_value(&v).is_ok(), "inline island refused");
1664    }
1665
1666    /// The blob loads, comes back split, and is then a fixed point of the
1667    /// markdown projection. The mark spans the slot, so the rebase the split
1668    /// owes it is what keeps `bold` addressed.
1669    #[test]
1670    fn a_stored_inline_block_island_survives_the_markdown_round_trip() {
1671        let slot = crate::model::ISLAND_SLOT;
1672        let json = concat!(
1673            r#"{"islands":[{"id":"isl-0","loss":"lossless","props":{"aligns":["none"],"#,
1674            r#""header":[{"marks":[],"text":"h"}],"rows":[[{"marks":[],"text":"c"}]]},"#,
1675            r#""type":"table"}],"lines":[{"containers":[],"kind":"para"}],"#,
1676            r#""marks":[{"end":6,"start":2,"type":"strong"}],"text":"abold"}"#
1677        );
1678        let rt = Content::from_canonical_json(json).expect("storage lane accepts");
1679        assert_eq!(rt.validate(), Ok(()), "loaded content invalid");
1680        assert_eq!(rt.text, format!("a\n{slot}\nbold"));
1681        assert_eq!(rt.lines.len(), 3);
1682        assert_eq!(rt.lines[1].kind, LineKind::Island);
1683        assert_eq!(rt.marks, vec![Mark::new(4, 8, MarkKind::Strong)]);
1684
1685        let md = crate::export::to_markdown(&rt);
1686        let back = crate::import::from_markdown(&md).expect("re-imports");
1687        assert_eq!(back, rt, "{md:?}");
1688    }
1689
1690    /// The `@0.93.0` spelling — every built-in's payload in named siblings —
1691    /// decodes unchanged. It goes with the sibling read, once no stored
1692    /// content is left in that shape: a question no schema tag can answer,
1693    /// since a `richtext` field rests as a content object under no tag.
1694    #[test]
1695    fn built_in_decoders_read_the_legacy_sibling_form() {
1696        let cases: [(Value, LineKind); 2] = [
1697            (
1698                serde_json::json!({"kind": "heading", "level": 2}),
1699                LineKind::Heading { level: 2 },
1700            ),
1701            (
1702                serde_json::json!({"kind": "code", "lang": "rust"}),
1703                LineKind::Code {
1704                    lang: Some("rust".into()),
1705                },
1706            ),
1707        ];
1708        for (v, want) in cases {
1709            assert_eq!(line_kind_from_value(&v).unwrap(), want);
1710        }
1711        let item = serde_json::json!({
1712            "container": "list_item", "ordered": true, "start": 3, "ordinal": 1
1713        });
1714        assert_eq!(
1715            container_from_value(&item).unwrap(),
1716            Container::ListItem {
1717                ordered: true,
1718                start: 3,
1719                ordinal: 1,
1720                instance: 0,
1721            }
1722        );
1723        for (v, want) in [
1724            (
1725                serde_json::json!({"start": 0, "end": 1, "type": "link", "url": "u"}),
1726                MarkKind::Link { url: "u".into() },
1727            ),
1728            (
1729                serde_json::json!({"start": 0, "end": 1, "type": "anchor", "id": "a1"}),
1730                MarkKind::Anchor { id: "a1".into() },
1731            ),
1732        ] {
1733            assert_eq!(mark_from_value(&v).unwrap().kind, want);
1734        }
1735        // Both spellings present: the bag is the spelling, and the sibling is
1736        // read only in its absence — so the bag wins and this stays a pure
1737        // fallback rather than a merge.
1738        let both = serde_json::json!({"kind": "heading", "level": 3, "attrs": {"level": 2}});
1739        assert_eq!(
1740            line_kind_from_value(&both).unwrap(),
1741            LineKind::Heading { level: 2 }
1742        );
1743        // Re-encode is the current spelling, so opening a legacy row and writing
1744        // it back moves its canonical bytes: read-repair, once per row.
1745        let legacy = r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"hi"}"#;
1746        assert_eq!(
1747            Content::from_canonical_json(legacy)
1748                .unwrap()
1749                .to_canonical_json(),
1750            r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":[],"kind":"heading"}],"marks":[],"text":"hi"}"#
1751        );
1752    }
1753
1754    /// An empty bag is one of the two spellings of *no payload*, so it cannot
1755    /// out-vote the sibling read a real one does: reading it as a bag would
1756    /// fail a `heading` outright and renumber a list item in silence.
1757    #[test]
1758    fn an_empty_bag_does_not_shadow_the_legacy_sibling() {
1759        assert_eq!(
1760            line_kind_from_value(&serde_json::json!({
1761                "kind": "heading", "level": 2, "attrs": {}
1762            }))
1763            .unwrap(),
1764            LineKind::Heading { level: 2 }
1765        );
1766        assert_eq!(
1767            container_from_value(&serde_json::json!({
1768                "container": "list_item", "attrs": {}, "ordered": true, "start": 3, "ordinal": 1
1769            }))
1770            .unwrap(),
1771            Container::ListItem {
1772                ordered: true,
1773                start: 3,
1774                ordinal: 1,
1775                instance: 0,
1776            }
1777        );
1778    }
1779
1780    /// The canonical tie-break is the `(type, attrs)` pair the wire carries,
1781    /// read back off the value, so canonical order is a function of the stored
1782    /// bytes rather than of variant declaration order.
1783    #[test]
1784    fn the_mark_tie_break_is_what_the_wire_carries() {
1785        let all = [
1786            MarkKind::Strong,
1787            MarkKind::Emph,
1788            MarkKind::Underline,
1789            MarkKind::Strike,
1790            MarkKind::Code,
1791            MarkKind::Link { url: "u".into() },
1792            MarkKind::Anchor { id: "a".into() },
1793        ];
1794        // Exhaustive on purpose: a new variant is a compile error here, where
1795        // the rule gets read.
1796        for k in &all {
1797            match k {
1798                MarkKind::Strong
1799                | MarkKind::Emph
1800                | MarkKind::Underline
1801                | MarkKind::Strike
1802                | MarkKind::Code
1803                | MarkKind::Link { .. }
1804                | MarkKind::Anchor { .. } => {}
1805            }
1806        }
1807        for k in &all {
1808            let wire = mark_to_value(&Mark::new(0, 1, k.clone()));
1809            let attrs = match wire.get("attrs") {
1810                Some(a) => crate::model::canonical_json_string(a),
1811                None => String::new(),
1812            };
1813            assert_eq!(
1814                k.sort_key(),
1815                (wire["type"].as_str().unwrap().to_string(), attrs),
1816                "{k:?}"
1817            );
1818        }
1819    }
1820
1821}