1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ParseError {
31 Shape(&'static str),
33 Json(String),
35 Invalid(crate::model::Invariant),
37 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 pub fn to_canonical_json(&self) -> String {
69 to_canonical_value(self).to_string()
70 }
71}
72
73impl Content {
74 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
128pub fn to_canonical_value(rt: &Normalized) -> Value {
134 let mut v = rt.to_value();
135 canonicalize_keys(&mut v);
140 v
141}
142
143fn 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
152pub fn from_canonical_value(v: &Value) -> Result<Normalized, ParseError> {
156 seal(Content::from_value(v)?)
157}
158
159fn seal(rt: Content) -> Result<Normalized, ParseError> {
162 let rt = rt.into_normalized();
163 rt.validate().map_err(ParseError::Invalid)?;
164 Ok(rt)
165}
166
167fn 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
185pub(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
200fn 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
210fn legacy_line_kind_keys(tag: &str) -> &'static [&'static str] {
218 match tag {
219 "heading" => &["level"],
220 "code" => &["lang"],
221 _ => &[],
222 }
223}
224
225fn legacy_container_keys(tag: &str) -> &'static [&'static str] {
228 match tag {
229 "list_item" => &["ordered", "ordinal", "start"],
230 _ => &[],
231 }
232}
233
234fn legacy_mark_keys(ty: &str) -> &'static [&'static str] {
237 match ty {
238 "link" => &["url"],
239 "anchor" => &["id"],
240 _ => &[],
241 }
242}
243
244fn 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
261fn 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
270pub fn line_kind_to_value(kind: &LineKind) -> Value {
273 Value::Object(line_kind_fields(kind))
274}
275
276fn line_kind_fields(kind: &LineKind) -> Map<String, Value> {
279 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
289pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
291 let o = v.as_object().ok_or(ParseError::Shape("line"))?;
292 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 if line.continues {
334 m.insert("continues".into(), Value::Bool(true));
335 }
336 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
360pub 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 if c.instance() != 0 {
370 m.insert("instance".into(), Value::from(c.instance()));
371 }
372 Value::Object(m)
373}
374
375pub 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
405pub 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
415struct MarkShape<'a> {
417 fields: &'a Map<String, Value>,
418 start: Usv,
419 end: Usv,
420 ty: &'a str,
421}
422
423fn 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
441pub 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
478pub(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
500fn 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
520pub(crate) fn container_from_authored_value(v: &Value) -> Result<Container, ParseError> {
523 reject_container_legacy(v)?;
524 container_from_value(v)
525}
526
527pub(crate) fn mark_from_authored_value(v: &Value) -> Result<Mark, ParseError> {
529 reject_mark_legacy(v)?;
530 mark_from_value(v)
531}
532
533pub(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
543pub(crate) fn island_from_authored_value(v: &Value) -> Result<Island, ParseError> {
547 reject_unwritable_image_url(v)?;
548 island_from_value(v)
549}
550
551pub(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
572fn 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
594pub 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
606fn 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
619fn 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 for m in arr_or_empty(v, "marks") {
633 reject_mark_legacy(m)?;
634 reject_unwritable_link_url(m)?;
635 }
636 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 Some(crate::island::IslandType::Image) => reject_unwritable_image_url(island)?,
655 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
679fn 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
705pub 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
724pub(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
735pub(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
745pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
748 table_cell_values(props).map(parse_cell).collect()
749}
750
751pub(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
803fn 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
822fn 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
832fn is_cell_break(c: char) -> bool {
835 c == '\n' || c == '\r' || crate::normalize::is_line_separator(c)
836}
837
838fn 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 (Some(o), Value::Object(fields)) => o.extend(fields),
853 (_, 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 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
901pub(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 #[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 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 #[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 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 #[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 assert!(matches!(
1019 from_authored_value(&v),
1020 Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
1021 ));
1022 }
1023 }
1024
1025 #[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 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 #[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 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 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 #[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}); assert_eq!(
1119 one.into_normalized().to_canonical_json(),
1120 two.into_normalized().to_canonical_json()
1121 );
1122 }
1123
1124 #[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 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 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 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 #[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 #[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 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 #[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 #[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 #[test]
1437 fn authored_lane_rejects_the_legacy_payload_spelling() {
1438 let bad = [
1439 r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"x"}"#,
1441 r#"{"islands":[],"lines":[{"containers":[{"container":"list_item","ordered":true}],"kind":"para"}],"marks":[],"text":"x"}"#,
1443 r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":1,"start":0,"type":"link","url":"u"}],"text":"x"}"#,
1445 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 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 assert!(
1469 Content::from_canonical_json(json).is_ok(),
1470 "storage lane rejected: {json}"
1471 );
1472 }
1473 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 #[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 #[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 #[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 (
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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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}