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). `deserialize ∘ serialize` is a
9//! fixed point on canonical bytes.
10//!
11//! The seam encoding (Option A) and the storage encoding are the *same*
12//! canonical form — one serializer, not two to keep aligned.
13
14use crate::model::{
15    sort_keys_owned, sorted_value, Container, Invariant, Island, Line, LineKind, Loss, Mark,
16    MarkKind, Content, Usv,
17};
18use serde_json::{Map, Value};
19
20/// Why canonical-JSON parsing failed. Structural only — a well-formed producer
21/// (this crate's serializer, the seam, storage) never trips these.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ParseError {
24    /// Top-level JSON was not an object, or a required key was missing/mistyped.
25    Shape(&'static str),
26    /// The JSON itself did not parse.
27    Json(String),
28    /// The value parsed but violates a content invariant.
29    Invalid(crate::model::Invariant),
30}
31
32impl std::fmt::Display for ParseError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            ParseError::Shape(s) => write!(f, "content json shape: {s}"),
36            ParseError::Json(s) => write!(f, "content json parse: {s}"),
37            ParseError::Invalid(inv) => write!(f, "content invariant: {inv:?}"),
38        }
39    }
40}
41impl std::error::Error for ParseError {}
42
43impl Content {
44    /// Serialize to canonical JSON bytes. Normalizes a copy first, so the output
45    /// is canonical regardless of the caller's mark/island order. Every object
46    /// key is sorted recursively so the bytes do **not** depend on
47    /// `serde_json`'s `preserve_order` feature being enabled in the consumer's
48    /// crate graph — the canonical form is feature-independent.
49    pub fn to_canonical_json(&self) -> String {
50        to_canonical_value(self).to_string()
51    }
52
53    /// Parse canonical JSON, normalize (idempotent), and validate. Returns
54    /// [`ParseError::Invalid`] for a content that violates its invariants, so
55    /// storage cannot silently round-trip a malformed value.
56    /// `from_canonical_json(to_canonical_json(x))` round-trips to a canonical
57    /// value and re-serializes to identical bytes.
58    pub fn from_canonical_json(s: &str) -> Result<Content, ParseError> {
59        let v: Value = serde_json::from_str(s).map_err(|e| ParseError::Json(e.to_string()))?;
60        from_canonical_value(&v)
61    }
62
63    fn to_value(&self) -> Value {
64        let mut root = Map::new();
65        root.insert("text".into(), Value::String(self.text.clone()));
66        root.insert(
67            "lines".into(),
68            Value::Array(self.lines.iter().map(line_to_value).collect()),
69        );
70        root.insert(
71            "marks".into(),
72            Value::Array(self.marks.iter().map(mark_to_value).collect()),
73        );
74        root.insert(
75            "islands".into(),
76            Value::Array(self.islands.iter().map(island_to_value).collect()),
77        );
78        Value::Object(root)
79    }
80
81    fn from_value(v: &Value) -> Result<Content, ParseError> {
82        let obj = v.as_object().ok_or(ParseError::Shape("root not object"))?;
83        let text = obj
84            .get("text")
85            .and_then(Value::as_str)
86            .ok_or(ParseError::Shape("text"))?
87            .to_string();
88        let lines = arr(obj, "lines")?
89            .iter()
90            .map(line_from_value)
91            .collect::<Result<_, _>>()?;
92        let marks = arr(obj, "marks")?
93            .iter()
94            .map(mark_from_value)
95            .collect::<Result<_, _>>()?;
96        let islands = arr(obj, "islands")?
97            .iter()
98            .map(island_from_value)
99            .collect::<Result<_, _>>()?;
100        Ok(Content {
101            text,
102            lines,
103            marks,
104            islands,
105        })
106    }
107}
108
109/// The canonical content form as a structural [`Value`] — the recursively
110/// key-sorted tree [`Content::to_canonical_json`] renders to bytes. A storage
111/// layer embeds this as a nested object (never an escaped string): serializing
112/// the returned value with `serde_json` is byte-identical to that JSON
113/// (`to_canonical_value(rt).to_string() == rt.to_canonical_json()`), independent
114/// of the consumer's `preserve_order` feature. Normalizes a copy first, so the
115/// value is canonical whatever the caller's mark/island order.
116pub fn to_canonical_value(rt: &Content) -> Value {
117    let mut rt = rt.clone();
118    rt.normalize();
119    sort_keys_owned(rt.to_value())
120}
121
122/// Parse the canonical content form from a structural [`Value`], normalize
123/// (idempotent), and validate — the [`Value`]-input counterpart to
124/// [`Content::from_canonical_json`]. Returns [`ParseError::Invalid`] for a
125/// content that violates its invariants, so a storage layer parsing the embedded
126/// object rejects a malformed value at load rather than round-tripping it.
127pub fn from_canonical_value(v: &Value) -> Result<Content, ParseError> {
128    let mut rt = Content::from_value(v)?;
129    rt.normalize();
130    rt.validate().map_err(ParseError::Invalid)?;
131    Ok(rt)
132}
133
134/// Read a wire position as a [`Usv`] index. **Checked**, not `as usize`: the
135/// deployment target is wasm32, where the truncating cast turns `2^32 + 5` into
136/// an in-range `5` — a mark silently landing at the wrong position instead of a
137/// rejected document. Every position the decoder reads goes through here.
138pub(crate) fn usv_from(v: Option<&Value>, what: &'static str) -> Result<Usv, ParseError> {
139    let n = v.and_then(Value::as_u64).ok_or(ParseError::Shape(what))?;
140    Usv::try_from(n).map_err(|_| ParseError::Shape(what))
141}
142
143fn arr<'a>(obj: &'a Map<String, Value>, key: &'static str) -> Result<&'a Vec<Value>, ParseError> {
144    obj.get(key)
145        .and_then(Value::as_array)
146        .ok_or(ParseError::Shape(key))
147}
148
149// ---- Line ----
150
151/// Encode a [`LineKind`] into its canonical `kind` fields (`"para"`,
152/// `{"kind":"heading","level":n}`, …). Public so the mark/line **op** wire
153/// ([`crate::ops`]) reuses the exact discriminant a `ContentLine` carries,
154/// rather than forking the encoding.
155pub fn line_kind_to_value(kind: &LineKind) -> Value {
156    let mut m = Map::new();
157    match kind {
158        LineKind::Para => {
159            m.insert("kind".into(), "para".into());
160        }
161        LineKind::Heading { level } => {
162            m.insert("kind".into(), "heading".into());
163            m.insert("level".into(), Value::from(*level));
164        }
165        LineKind::Code { lang } => {
166            m.insert("kind".into(), "code".into());
167            if let Some(l) = lang {
168                m.insert("lang".into(), Value::String(l.clone()));
169            }
170        }
171        LineKind::Island => {
172            m.insert("kind".into(), "island".into());
173        }
174        LineKind::Rule => {
175            m.insert("kind".into(), "rule".into());
176        }
177        // Open set, the mark encoding one axis over: the tag *is* the
178        // discriminator and the payload rides one opaque `attrs` bag, so a
179        // reader that lacks the role still carries it whole.
180        LineKind::Unknown { tag, attrs } => {
181            m.insert("kind".into(), Value::String(tag.clone()));
182            m.insert("attrs".into(), sorted_value(attrs));
183        }
184    }
185    Value::Object(m)
186}
187
188/// Decode a [`LineKind`] from an object carrying the canonical `kind` fields.
189/// The inverse of [`line_kind_to_value`]; the shared line-kind reader for
190/// [`line_from_value`] and the line-op wire.
191pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
192    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
193    match o.get("kind").and_then(Value::as_str) {
194        Some("para") => Ok(LineKind::Para),
195        Some("heading") => {
196            let level = o
197                .get("level")
198                .and_then(Value::as_u64)
199                .ok_or(ParseError::Shape("heading level"))?;
200            if !(1..=6).contains(&level) {
201                return Err(ParseError::Shape("heading level"));
202            }
203            Ok(LineKind::Heading { level: level as u8 })
204        }
205        Some("code") => Ok(LineKind::Code {
206            lang: o.get("lang").and_then(Value::as_str).map(str::to_string),
207        }),
208        Some("island") => Ok(LineKind::Island),
209        Some("rule") => Ok(LineKind::Rule),
210        // Open set: any other name is a block role this build lacks, kept opaque
211        // and projected as `Para`. Only a missing/non-string `kind` is a shape
212        // error — the *document* still opens when its vocabulary grows.
213        Some(other) => Ok(LineKind::Unknown {
214            tag: other.to_string(),
215            attrs: o.get("attrs").cloned().unwrap_or(Value::Null),
216        }),
217        None => Err(ParseError::Shape("line kind")),
218    }
219}
220
221fn line_to_value(line: &Line) -> Value {
222    let Value::Object(mut m) = line_kind_to_value(&line.kind) else {
223        unreachable!("line_kind_to_value always returns an object")
224    };
225    m.insert(
226        "containers".into(),
227        Value::Array(line.containers.iter().map(container_to_value).collect()),
228    );
229    // Omitted when false (the common case) — deterministic since presence is a
230    // pure function of the value.
231    if line.continues {
232        m.insert("continues".into(), Value::Bool(true));
233    }
234    Value::Object(m)
235}
236
237fn line_from_value(v: &Value) -> Result<Line, ParseError> {
238    let o = v.as_object().ok_or(ParseError::Shape("line"))?;
239    let kind = line_kind_from_value(v)?;
240    let containers = o
241        .get("containers")
242        .and_then(Value::as_array)
243        .ok_or(ParseError::Shape("containers"))?
244        .iter()
245        .map(container_from_value)
246        .collect::<Result<_, _>>()?;
247    let continues = o.get("continues").and_then(Value::as_bool).unwrap_or(false);
248    Ok(Line {
249        kind,
250        containers,
251        continues,
252    })
253}
254
255/// Encode a [`Container`] into its canonical wire object. Public so the line-op
256/// wire ([`crate::ops`]) reuses the same container shape a `ContentLine`
257/// carries.
258pub fn container_to_value(c: &Container) -> Value {
259    let mut m = Map::new();
260    match c {
261        Container::ListItem {
262            ordered,
263            start,
264            ordinal,
265        } => {
266            m.insert("container".into(), "list_item".into());
267            m.insert("ordered".into(), Value::Bool(*ordered));
268            m.insert("start".into(), Value::from(*start));
269            m.insert("ordinal".into(), Value::from(*ordinal));
270        }
271        Container::Quote => {
272            m.insert("container".into(), "quote".into());
273        }
274        Container::Unknown { tag, attrs } => {
275            m.insert("container".into(), Value::String(tag.clone()));
276            m.insert("attrs".into(), sorted_value(attrs));
277        }
278    }
279    Value::Object(m)
280}
281
282/// Decode a [`Container`] from its canonical wire object. The inverse of
283/// [`container_to_value`].
284pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
285    let o = v.as_object().ok_or(ParseError::Shape("container"))?;
286    match o.get("container").and_then(Value::as_str) {
287        Some("list_item") => Ok(Container::ListItem {
288            ordered: o.get("ordered").and_then(Value::as_bool).unwrap_or(false),
289            start: o.get("start").and_then(Value::as_u64).unwrap_or(1),
290            ordinal: o.get("ordinal").and_then(Value::as_u64).unwrap_or(0),
291        }),
292        Some("quote") => Ok(Container::Quote),
293        // Open set, as for line kinds: an unrecognized container round-trips
294        // opaque and projects transparently.
295        Some(other) => Ok(Container::Unknown {
296            tag: other.to_string(),
297            attrs: o.get("attrs").cloned().unwrap_or(Value::Null),
298        }),
299        None => Err(ParseError::Shape("container kind")),
300    }
301}
302
303// ---- Mark ----
304
305/// Encode a [`Mark`] (`{start, end, type, …}`) into its canonical wire object.
306/// Public so the mark-op wire ([`crate::ops`]) reuses the exact `type`
307/// discriminant a `ContentMark` carries.
308pub fn mark_to_value(mark: &Mark) -> Value {
309    let mut m = Map::new();
310    m.insert("start".into(), Value::from(mark.start));
311    m.insert("end".into(), Value::from(mark.end));
312    match &mark.kind {
313        MarkKind::Strong => {
314            m.insert("type".into(), "strong".into());
315        }
316        MarkKind::Emph => {
317            m.insert("type".into(), "emph".into());
318        }
319        MarkKind::Underline => {
320            m.insert("type".into(), "underline".into());
321        }
322        MarkKind::Strike => {
323            m.insert("type".into(), "strike".into());
324        }
325        MarkKind::Code => {
326            m.insert("type".into(), "code".into());
327        }
328        MarkKind::Link { url } => {
329            m.insert("type".into(), "link".into());
330            m.insert("url".into(), Value::String(url.clone()));
331        }
332        MarkKind::Anchor { id } => {
333            m.insert("type".into(), "anchor".into());
334            m.insert("id".into(), Value::String(id.clone()));
335        }
336        MarkKind::Unknown { tag, attrs } => {
337            m.insert("type".into(), Value::String(tag.clone()));
338            m.insert("attrs".into(), sorted_value(attrs));
339        }
340    }
341    Value::Object(m)
342}
343
344/// Decode a [`Mark`] from its canonical wire object. The inverse of
345/// [`mark_to_value`]; the shared mark reader for the content decoder and the
346/// mark-op wire.
347pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
348    let o = v.as_object().ok_or(ParseError::Shape("mark"))?;
349    let start = usv_from(o.get("start"), "mark start")?;
350    let end = usv_from(o.get("end"), "mark end")?;
351    let ty = o
352        .get("type")
353        .and_then(Value::as_str)
354        .ok_or(ParseError::Shape("mark type"))?;
355    let kind = match ty {
356        "strong" => MarkKind::Strong,
357        "emph" => MarkKind::Emph,
358        "underline" => MarkKind::Underline,
359        "strike" => MarkKind::Strike,
360        "code" => MarkKind::Code,
361        "link" => MarkKind::Link {
362            url: o
363                .get("url")
364                .and_then(Value::as_str)
365                .unwrap_or_default()
366                .to_string(),
367        },
368        "anchor" => MarkKind::Anchor {
369            id: o
370                .get("id")
371                .and_then(Value::as_str)
372                .unwrap_or_default()
373                .to_string(),
374        },
375        // Open set: any other type name is an unknown mark, round-tripped opaque
376        // with whatever `attrs` it carried.
377        other => MarkKind::Unknown {
378            tag: other.to_string(),
379            attrs: o.get("attrs").cloned().unwrap_or(Value::Null),
380        },
381    };
382    Ok(Mark { start, end, kind })
383}
384
385// ---- Table cell {text, marks} ----
386//
387// A pipe-table cell is inline-only: its own plain `text` plus `marks` whose
388// ranges are USV offsets into that text (0..cell_len). The marks ride the SAME
389// wire shape prose marks use (`mark_to_value`/`mark_from_value`), so nothing
390// forks the encoding. Import builds cells, export/emit render them, and
391// `Content::normalize`/`validate` canonicalize/check the marks — all through
392// these helpers.
393
394/// Parse a table-cell object `{text, marks}` leniently: its plain text plus the
395/// marks over it. A malformed mark is skipped rather than failing — cells are
396/// flat inline, so this never recurses. Public so the typst emitter renders a
397/// cell through the same parse the codecs use.
398pub fn parse_cell(v: &Value) -> (String, Vec<Mark>) {
399    let text = v
400        .get("text")
401        .and_then(Value::as_str)
402        .unwrap_or_default()
403        .to_string();
404    let marks = v
405        .get("marks")
406        .and_then(Value::as_array)
407        .map(|arr| arr.iter().filter_map(|m| mark_from_value(m).ok()).collect())
408        .unwrap_or_default();
409    (text, marks)
410}
411
412/// Build a table-cell object `{text, marks}` — the inverse of [`parse_cell`],
413/// reusing [`mark_to_value`]. Key order is fixed by the recursive
414/// [`sorted_value`] pass in [`Content::normalize`], not here.
415pub(crate) fn cell_to_value(text: &str, marks: &[Mark]) -> Value {
416    let mut m = Map::new();
417    m.insert("text".into(), Value::String(text.to_string()));
418    m.insert(
419        "marks".into(),
420        Value::Array(marks.iter().map(mark_to_value).collect()),
421    );
422    Value::Object(m)
423}
424
425/// Every cell's `(text, marks)` in a table island's props — header then each
426/// body row, in order. For [`Content::validate`]'s cell-mark invariant checks.
427pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
428    let mut out = Vec::new();
429    if let Some(h) = props.get("header").and_then(Value::as_array) {
430        out.extend(h.iter().map(parse_cell));
431    }
432    if let Some(rows) = props.get("rows").and_then(Value::as_array) {
433        for row in rows {
434            if let Some(r) = row.as_array() {
435                out.extend(r.iter().map(parse_cell));
436            }
437        }
438    }
439    out
440}
441
442// The `table` codec below (props normalize, shape-validate, cell extraction) is
443// the primitive `crate::island` dispatches into for `KnownIslandType::Table`;
444// island-type dispatch itself lives there, not here.
445
446/// Repair a table island's props in place to the canonical shape:
447///
448/// - **One column count.** `cols` is the widest of the header, any body row, and
449///   `aligns`; the header, each row, and `aligns` are padded up to it (padding
450///   only grows — no cell is ever truncated). Materializing the count into the
451///   header means the markdown projection (header-derived) and the Typst
452///   projection (widest-row) agree on one number.
453/// - **Single-line cells.** Any `\n`/`\r` in a cell's text becomes a space (the
454///   same rule import applies to soft/hard breaks). A 1:1 replacement keeps char
455///   offsets stable, so the cell's marks stay in range.
456/// - **Canonical cell marks.** Each cell's marks are re-normalized (sort,
457///   same-kind union, drop zero-width) so equal cells serialize to equal bytes.
458pub(crate) fn normalize_table_props(props: &mut Value) {
459    let cols = table_cols(props);
460    let Some(obj) = props.as_object_mut() else {
461        return;
462    };
463    let header = obj.entry("header").or_insert_with(|| Value::Array(vec![]));
464    // A non-array header (a bare string, say) carries no cells; rewrite it to an
465    // empty array so it canonicalizes to a zero-column, content-free table
466    // rather than retaining opaque garbage that `validate` would then reject.
467    if !header.is_array() {
468        *header = Value::Array(vec![]);
469    }
470    pad_row(header, cols);
471    if let Some(h) = header.as_array_mut() {
472        h.iter_mut().for_each(canon_cell);
473    }
474    let aligns = obj.entry("aligns").or_insert_with(|| Value::Array(vec![]));
475    if let Some(a) = aligns.as_array_mut() {
476        while a.len() < cols {
477            a.push(Value::String("none".into()));
478        }
479    }
480    if let Some(rows) = obj.get_mut("rows").and_then(Value::as_array_mut) {
481        for row in rows.iter_mut() {
482            pad_row(row, cols);
483            if let Some(r) = row.as_array_mut() {
484                r.iter_mut().for_each(canon_cell);
485            }
486        }
487    }
488}
489
490/// A table's canonical column count: the widest of its header, any body row, and
491/// its `aligns` array. Padding (never truncation) brings every part up to it.
492fn table_cols(props: &Value) -> usize {
493    let arr_len = |k: &str| props.get(k).and_then(Value::as_array).map(|a| a.len());
494    let header = arr_len("header").unwrap_or(0);
495    let aligns = arr_len("aligns").unwrap_or(0);
496    let widest_row = props
497        .get("rows")
498        .and_then(Value::as_array)
499        .map(|rows| {
500            rows.iter()
501                .map(|r| r.as_array().map(|a| a.len()).unwrap_or(0))
502                .max()
503                .unwrap_or(0)
504        })
505        .unwrap_or(0);
506    header.max(aligns).max(widest_row)
507}
508
509/// Pad a cell array (header or body row) up to `cols` with empty cells. Never
510/// shrinks — `cols` is the widest, so a shorter array only grows.
511fn pad_row(v: &mut Value, cols: usize) {
512    if let Some(arr) = v.as_array_mut() {
513        while arr.len() < cols {
514            arr.push(cell_to_value("", &[]));
515        }
516    }
517}
518
519/// De-newline a cell's text (each `\n`/`\r` → a space, 1:1 so mark offsets hold)
520/// and re-normalize its marks. Reached per-cell from [`normalize_table_props`].
521fn canon_cell(cell: &mut Value) {
522    let (text, marks) = parse_cell(cell);
523    let text = if text.contains(['\n', '\r']) {
524        text.replace(['\n', '\r'], " ")
525    } else {
526        text
527    };
528    *cell = cell_to_value(&text, &crate::model::normalize_marks(marks));
529}
530
531/// A table island's shape violation, if any — the widths the header, `aligns`,
532/// and each body row must share (the header width), plus the `\n`-free-cell rule.
533/// The validate-side twin of [`normalize_table_props`].
534pub(crate) fn table_shape_error(props: &Value) -> Option<Invariant> {
535    // A present-but-non-array header can't carry column cells — `normalize`
536    // rewrites it to an empty array, so an un-normalized one is a hand-built
537    // degenerate island. (An absent header is a zero-column table, which is
538    // well-formed: `empty_table_is_valid`.)
539    if props.get("header").is_some_and(|h| !h.is_array()) {
540        return Some(Invariant::TableHeaderNotArray);
541    }
542    let cols = props
543        .get("header")
544        .and_then(Value::as_array)
545        .map(|a| a.len())
546        .unwrap_or(0);
547    let aligns = props
548        .get("aligns")
549        .and_then(Value::as_array)
550        .map(|a| a.len())
551        .unwrap_or(0);
552    if aligns != cols {
553        return Some(Invariant::TableAlignsMismatch { aligns, cols });
554    }
555    if let Some(rows) = props.get("rows").and_then(Value::as_array) {
556        for (i, row) in rows.iter().enumerate() {
557            let width = row.as_array().map(|a| a.len()).unwrap_or(0);
558            if width != cols {
559                return Some(Invariant::TableRaggedRow {
560                    row: i,
561                    width,
562                    cols,
563                });
564            }
565        }
566    }
567    for (i, (text, _)) in table_cells(props).iter().enumerate() {
568        if text.contains('\n') || text.contains('\r') {
569            return Some(Invariant::TableCellNewline { cell: i });
570        }
571    }
572    None
573}
574
575// ---- Island ----
576
577fn island_to_value(island: &Island) -> Value {
578    let mut m = Map::new();
579    m.insert("id".into(), Value::String(island.id.clone()));
580    m.insert("type".into(), Value::String(island.island_type.clone()));
581    m.insert("props".into(), sorted_value(&island.props));
582    m.insert("loss".into(), loss_to_str(island.loss).into());
583    Value::Object(m)
584}
585
586fn island_from_value(v: &Value) -> Result<Island, ParseError> {
587    let o = v.as_object().ok_or(ParseError::Shape("island"))?;
588    Ok(Island {
589        id: o
590            .get("id")
591            .and_then(Value::as_str)
592            .ok_or(ParseError::Shape("island id"))?
593            .to_string(),
594        island_type: o
595            .get("type")
596            .and_then(Value::as_str)
597            .ok_or(ParseError::Shape("island type"))?
598            .to_string(),
599        props: o.get("props").cloned().unwrap_or(Value::Null),
600        loss: loss_from_str(o.get("loss").and_then(Value::as_str).unwrap_or("lossless")),
601    })
602}
603
604fn loss_to_str(loss: Loss) -> &'static str {
605    match loss {
606        Loss::Lossless => "lossless",
607        Loss::Degraded => "degraded",
608        Loss::Unrepresentable => "unrepresentable",
609    }
610}
611
612fn loss_from_str(s: &str) -> Loss {
613    match s {
614        "lossless" => Loss::Lossless,
615        "degraded" => Loss::Degraded,
616        // Unknown/future loss class defaults to the *safe* end: never claim a
617        // value the reader can't interpret "carries faithfully".
618        _ => Loss::Unrepresentable,
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::model::{Line, LineKind};
626
627    fn sample() -> Content {
628        Content {
629            text: "hello world".into(),
630            lines: vec![Line {
631                kind: LineKind::Para,
632                containers: vec![],
633                continues: false,
634            }],
635            marks: vec![
636                Mark {
637                    start: 6,
638                    end: 11,
639                    kind: MarkKind::Strong,
640                },
641                Mark {
642                    start: 0,
643                    end: 5,
644                    kind: MarkKind::Emph,
645                },
646            ],
647            islands: vec![],
648        }
649    }
650
651    /// Issue #1051: the decoder is the entry point for stored and
652    /// caller-supplied content, and export recurses one frame per container. A
653    /// 20 000-deep path used to decode clean and abort the process on
654    /// `to_markdown`; the shared `validate` cap rejects it at the door.
655    #[test]
656    fn deep_container_nesting_is_rejected_at_decode() {
657        let containers = vec![r#"{"container":"quote"}"#; 20_000].join(",");
658        let json = format!(
659            r#"{{"text":"hi","lines":[{{"kind":"para","containers":[{containers}]}}],"marks":[],"islands":[]}}"#
660        );
661        assert!(matches!(
662            Content::from_canonical_json(&json),
663            Err(ParseError::Invalid(Invariant::NestingTooDeep { .. }))
664        ));
665    }
666
667    /// Issue #1051: a wire position past `usize` is refused, not truncated. On
668    /// wasm32 — the deployment target — `as usize` turned `2^32 + 5` into an
669    /// in-range `5`, landing a mark at the wrong position in a document that
670    /// then validated clean. Rejected on every target, by the checked read here
671    /// on 32-bit and by the range invariant on 64-bit.
672    #[test]
673    fn out_of_range_wire_position_is_refused() {
674        let json = r#"{"text":"hello","lines":[{"kind":"para","containers":[]}],"marks":[{"start":4294967301,"end":4294967302,"type":"strong"}],"islands":[]}"#;
675        assert!(Content::from_canonical_json(json).is_err());
676        assert!(usv_from(Some(&Value::from(u64::MAX)), "x").is_ok() || usize::BITS < 64);
677        assert!(usv_from(Some(&Value::from(-1i64)), "x").is_err());
678    }
679
680    #[test]
681    fn island_props_key_order_does_not_leak() {
682        let mut one = Content::empty();
683        one.text = "\u{FFFC}".into();
684        one.lines = vec![Line {
685            kind: LineKind::Island,
686            containers: vec![],
687            continues: false,
688        }];
689        one.islands = vec![Island {
690            id: "i1".into(),
691            island_type: "table".into(),
692            props: serde_json::json!({"b": 1, "a": 2}),
693            loss: Loss::Lossless,
694        }];
695        let mut two = one.clone();
696        two.islands[0].props = serde_json::json!({"a": 2, "b": 1}); // keys reversed
697        assert_eq!(one.to_canonical_json(), two.to_canonical_json());
698    }
699
700    #[test]
701    fn golden_bytes_are_feature_independent() {
702        // Pins the exact canonical form. Every object key is sorted, so the
703        // bytes do not depend on serde_json's preserve_order feature. If this
704        // string changes, the freeze changed — bump the schema version.
705        let rt = sample();
706        assert_eq!(
707            rt.to_canonical_json(),
708            r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":5,"start":0,"type":"emph"},{"end":11,"start":6,"type":"strong"}],"text":"hello world"}"#
709        );
710    }
711
712    #[test]
713    fn from_canonical_json_rejects_invalid() {
714        // lines.len() != segment count — must not silently round-trip.
715        let bad =
716            r#"{"text":"a\nb","lines":[{"kind":"para","containers":[]}],"marks":[],"islands":[]}"#;
717        assert!(matches!(
718            Content::from_canonical_json(bad),
719            Err(ParseError::Invalid(_))
720        ));
721    }
722
723    #[test]
724    fn reserved_unknown_tag_rejected() {
725        // An Unknown mark may not reuse a built-in type name (would parse back
726        // as the built-in, dropping attrs — non-injective).
727        let mut rt = Content::empty();
728        rt.text = "abcd".into();
729        rt.marks = vec![Mark {
730            start: 0,
731            end: 4,
732            kind: MarkKind::Unknown {
733                tag: "strong".into(),
734                attrs: serde_json::json!({}),
735            },
736        }];
737        assert!(matches!(
738            rt.validate(),
739            Err(crate::model::Invariant::ReservedUnknownTag(_))
740        ));
741    }
742
743    #[test]
744    fn unknown_loss_class_defaults_unrepresentable() {
745        let json = r#"{"text":"","lines":[{"kind":"island","containers":[]}],"marks":[],"islands":[{"id":"i1","type":"widget","props":{},"loss":"future_class"}]}"#;
746        let rt = Content::from_canonical_json(json).unwrap();
747        assert_eq!(rt.islands[0].loss, Loss::Unrepresentable);
748    }
749
750    /// Issue #1054: the block vocabulary is open on the mark axis' terms. A
751    /// `kind`/`container` this build lacks decodes to `Unknown` — the document
752    /// **opens** — and re-encodes byte-identically, so a construct a future
753    /// reader understands survives the trip through this one.
754    #[test]
755    fn unknown_line_kind_and_container_round_trip_opaque() {
756        let json = concat!(
757            r#"{"islands":[],"lines":[{"attrs":{"variant":"warn"},"containers":"#,
758            r#"[{"attrs":{"depth":2},"container":"indent"}],"kind":"callout"}],"#,
759            r#""marks":[],"text":"heads up"}"#
760        );
761        let rt = Content::from_canonical_json(json).unwrap();
762        assert_eq!(
763            rt.lines[0].kind,
764            LineKind::Unknown {
765                tag: "callout".into(),
766                attrs: serde_json::json!({"variant": "warn"}),
767            }
768        );
769        assert_eq!(
770            rt.lines[0].containers,
771            vec![Container::Unknown {
772                tag: "indent".into(),
773                attrs: serde_json::json!({"depth": 2}),
774            }]
775        );
776        assert_eq!(rt.to_canonical_json(), json);
777        // An attrs-free unknown decodes too (`attrs` is null, not a shape error).
778        let bare = r#"{"islands":[],"lines":[{"containers":[],"kind":"footnote"}],"marks":[],"text":"x"}"#;
779        let rt = Content::from_canonical_json(bare).unwrap();
780        assert_eq!(
781            rt.lines[0].kind,
782            LineKind::Unknown {
783                tag: "footnote".into(),
784                attrs: Value::Null,
785            }
786        );
787        // A missing/non-string discriminator is still a shape error — the open
788        // set absorbs unknown *names*, not malformed objects.
789        for bad in [
790            r#"{"islands":[],"lines":[{"containers":[]}],"marks":[],"text":"x"}"#,
791            r#"{"islands":[],"lines":[{"containers":[{"container":7}],"kind":"para"}],"marks":[],"text":"x"}"#,
792        ] {
793            assert!(matches!(
794                Content::from_canonical_json(bad),
795                Err(ParseError::Shape(_))
796            ));
797        }
798    }
799
800    /// Issue #1054: an unknown line kind / container may not reuse a built-in
801    /// name — it would serialize as the built-in and parse back as one, dropping
802    /// its attrs (the `ReservedUnknownTag` rule, one axis over).
803    #[test]
804    fn reserved_block_vocabulary_names_rejected() {
805        let mut rt = Content::empty();
806        rt.text = "abcd".into();
807        rt.lines[0].kind = LineKind::Unknown {
808            tag: "heading".into(),
809            attrs: serde_json::json!({}),
810        };
811        assert_eq!(
812            rt.validate(),
813            Err(Invariant::ReservedUnknownLineKind("heading".into()))
814        );
815        rt.lines[0].kind = LineKind::Para;
816        rt.lines[0].containers = vec![Container::Unknown {
817            tag: "quote".into(),
818            attrs: serde_json::json!({}),
819        }];
820        assert_eq!(
821            rt.validate(),
822            Err(Invariant::ReservedUnknownContainer("quote".into()))
823        );
824    }
825
826    /// Issue #1054: opaque block attrs are hash input, so their key order must
827    /// not leak into the canonical bytes — the unknown-mark rule, one axis over.
828    #[test]
829    fn unknown_block_attrs_key_order_does_not_leak() {
830        let mut one = Content::empty();
831        one.text = "hi".into();
832        one.lines[0].kind = LineKind::Unknown {
833            tag: "callout".into(),
834            attrs: serde_json::json!({"b": 1, "a": 2}),
835        };
836        one.lines[0].containers = vec![Container::Unknown {
837            tag: "indent".into(),
838            attrs: serde_json::json!({"y": 1, "x": 2}),
839        }];
840        let mut two = one.clone();
841        two.lines[0].kind = LineKind::Unknown {
842            tag: "callout".into(),
843            attrs: serde_json::json!({"a": 2, "b": 1}),
844        };
845        two.lines[0].containers = vec![Container::Unknown {
846            tag: "indent".into(),
847            attrs: serde_json::json!({"x": 2, "y": 1}),
848        }];
849        assert_eq!(one.to_canonical_json(), two.to_canonical_json());
850        one.normalize();
851        two.normalize();
852        assert_eq!(one, two, "normalize canonicalizes the live model too");
853    }
854
855    #[test]
856    fn unknown_mark_round_trips_opaque() {
857        let mut rt = Content::empty();
858        rt.text = "abcd".into();
859        rt.marks = vec![Mark {
860            start: 0,
861            end: 4,
862            kind: MarkKind::Unknown {
863                tag: "highlight".into(),
864                attrs: serde_json::json!({"color": "yellow"}),
865            },
866        }];
867        let json = rt.to_canonical_json();
868        let back = Content::from_canonical_json(&json).unwrap();
869        assert_eq!(back.marks[0].kind, rt.marks[0].kind);
870    }
871}