use crate::model::{
sort_keys_owned, Container, Invariant, Island, Line, LineKind, Loss, Mark,
MarkKind, Content, Usv,
};
use serde_json::{Map, Value};
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
Shape(&'static str),
Json(String),
Invalid(crate::model::Invariant),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Shape(s) => write!(f, "content json shape: {s}"),
ParseError::Json(s) => write!(f, "content json parse: {s}"),
ParseError::Invalid(inv) => write!(f, "content invariant: {inv:?}"),
}
}
}
impl std::error::Error for ParseError {}
impl Content {
pub fn to_canonical_json(&self) -> String {
to_canonical_value(self).to_string()
}
pub fn from_canonical_json(s: &str) -> Result<Content, ParseError> {
let v: Value = serde_json::from_str(s).map_err(|e| ParseError::Json(e.to_string()))?;
from_canonical_value(&v)
}
fn to_value(&self) -> Value {
let mut root = Map::new();
root.insert("text".into(), Value::String(self.text.clone()));
root.insert(
"lines".into(),
Value::Array(self.lines.iter().map(line_to_value).collect()),
);
root.insert(
"marks".into(),
Value::Array(self.marks.iter().map(mark_to_value).collect()),
);
root.insert(
"islands".into(),
Value::Array(self.islands.iter().map(island_to_value).collect()),
);
Value::Object(root)
}
fn from_value(v: &Value) -> Result<Content, ParseError> {
let obj = v.as_object().ok_or(ParseError::Shape("root not object"))?;
let text = obj
.get("text")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("text"))?
.to_string();
let lines = arr(obj, "lines")?
.iter()
.map(line_from_value)
.collect::<Result<_, _>>()?;
let marks = arr(obj, "marks")?
.iter()
.map(mark_from_value)
.collect::<Result<_, _>>()?;
let islands = arr(obj, "islands")?
.iter()
.map(island_from_value)
.collect::<Result<_, _>>()?;
Ok(Content {
text,
lines,
marks,
islands,
})
}
}
pub fn to_canonical_value(rt: &Content) -> Value {
let mut rt = rt.clone();
rt.normalize();
sort_keys_owned(rt.to_value())
}
pub fn from_canonical_value(v: &Value) -> Result<Content, ParseError> {
let mut rt = Content::from_value(v)?;
rt.normalize();
rt.validate().map_err(ParseError::Invalid)?;
Ok(rt)
}
fn bag_from_wire(
o: &Map<String, Value>,
key: &'static str,
what: &'static str,
) -> Result<Value, ParseError> {
let Some(v) = o.get(key) else {
return Ok(Value::Null);
};
crate::model::check_json_depth(v, what).map_err(ParseError::Invalid)?;
Ok(v.clone())
}
pub(crate) fn usv_from(v: Option<&Value>, what: &'static str) -> Result<Usv, ParseError> {
let n = v.and_then(Value::as_u64).ok_or(ParseError::Shape(what))?;
Usv::try_from(n).map_err(|_| ParseError::Shape(what))
}
fn arr<'a>(obj: &'a Map<String, Value>, key: &'static str) -> Result<&'a Vec<Value>, ParseError> {
obj.get(key)
.and_then(Value::as_array)
.ok_or(ParseError::Shape(key))
}
fn as_slice(v: &Value) -> &[Value] {
v.as_array().map(Vec::as_slice).unwrap_or_default()
}
fn arr_or_empty<'a>(v: &'a Value, key: &str) -> &'a [Value] {
v.get(key).map(as_slice).unwrap_or_default()
}
fn fold_legacy_attrs<'a>(
o: &'a Map<String, Value>,
tag: &str,
reserved: &[&str],
what: &'static str,
) -> Result<Cow<'a, Map<String, Value>>, ParseError> {
let Some(bag @ Value::Object(attrs)) = o.get("attrs") else {
return Ok(Cow::Borrowed(o));
};
if attrs.is_empty() || !reserved.contains(&tag) {
return Ok(Cow::Borrowed(o));
}
crate::model::check_json_depth(bag, what).map_err(ParseError::Invalid)?;
let mut folded = o.clone();
for (k, v) in attrs {
folded.entry(k.clone()).or_insert_with(|| v.clone());
}
Ok(Cow::Owned(folded))
}
pub fn line_kind_to_value(kind: &LineKind) -> Value {
let mut m = Map::new();
match kind {
LineKind::Para => {
m.insert("kind".into(), "para".into());
}
LineKind::Heading { level } => {
m.insert("kind".into(), "heading".into());
m.insert("level".into(), Value::from(*level));
}
LineKind::Code { lang } => {
m.insert("kind".into(), "code".into());
if let Some(l) = lang {
m.insert("lang".into(), Value::String(l.clone()));
}
}
LineKind::Island => {
m.insert("kind".into(), "island".into());
}
LineKind::Rule => {
m.insert("kind".into(), "rule".into());
}
LineKind::Unknown { tag, attrs } => {
m.insert("kind".into(), Value::String(tag.clone()));
m.insert("attrs".into(), attrs.clone());
}
}
Value::Object(m)
}
pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("line"))?;
let tag = o
.get("kind")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("line kind"))?;
let o = fold_legacy_attrs(o, tag, Content::RESERVED_LINE_KINDS, "line attrs")?;
match tag {
"para" => Ok(LineKind::Para),
"heading" => {
let level = o
.get("level")
.and_then(Value::as_u64)
.ok_or(ParseError::Shape("heading level"))?;
if !(1..=6).contains(&level) {
return Err(ParseError::Shape("heading level"));
}
Ok(LineKind::Heading { level: level as u8 })
}
"code" => Ok(LineKind::Code {
lang: o.get("lang").and_then(Value::as_str).map(str::to_string),
}),
"island" => Ok(LineKind::Island),
"rule" => Ok(LineKind::Rule),
other => Ok(LineKind::Unknown {
tag: other.to_string(),
attrs: bag_from_wire(&o, "attrs", "line attrs")?,
}),
}
}
fn line_to_value(line: &Line) -> Value {
let Value::Object(mut m) = line_kind_to_value(&line.kind) else {
unreachable!("line_kind_to_value always returns an object")
};
m.insert(
"containers".into(),
Value::Array(line.containers.iter().map(container_to_value).collect()),
);
if line.continues {
m.insert("continues".into(), Value::Bool(true));
}
Value::Object(m)
}
fn line_from_value(v: &Value) -> Result<Line, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("line"))?;
let kind = line_kind_from_value(v)?;
let containers = o
.get("containers")
.and_then(Value::as_array)
.ok_or(ParseError::Shape("containers"))?
.iter()
.map(container_from_value)
.collect::<Result<_, _>>()?;
let continues = o.get("continues").and_then(Value::as_bool).unwrap_or(false);
Ok(Line {
kind,
containers,
continues,
})
}
pub fn container_to_value(c: &Container) -> Value {
let mut m = Map::new();
match c {
Container::ListItem {
ordered,
start,
ordinal,
} => {
m.insert("container".into(), "list_item".into());
m.insert("ordered".into(), Value::Bool(*ordered));
m.insert("start".into(), Value::from(*start));
m.insert("ordinal".into(), Value::from(*ordinal));
}
Container::Quote => {
m.insert("container".into(), "quote".into());
}
Container::Unknown { tag, attrs } => {
m.insert("container".into(), Value::String(tag.clone()));
m.insert("attrs".into(), attrs.clone());
}
}
Value::Object(m)
}
pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("container"))?;
let tag = o
.get("container")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("container kind"))?;
let o = fold_legacy_attrs(o, tag, Content::RESERVED_CONTAINERS, "container attrs")?;
match tag {
"list_item" => Ok(Container::ListItem {
ordered: o.get("ordered").and_then(Value::as_bool).unwrap_or(false),
start: o.get("start").and_then(Value::as_u64).unwrap_or(1),
ordinal: o.get("ordinal").and_then(Value::as_u64).unwrap_or(0),
}),
"quote" => Ok(Container::Quote),
other => Ok(Container::Unknown {
tag: other.to_string(),
attrs: bag_from_wire(&o, "attrs", "container attrs")?,
}),
}
}
pub fn mark_to_value(mark: &Mark) -> Value {
let mut m = Map::new();
m.insert("start".into(), Value::from(mark.start));
m.insert("end".into(), Value::from(mark.end));
match &mark.kind {
MarkKind::Strong => {
m.insert("type".into(), "strong".into());
}
MarkKind::Emph => {
m.insert("type".into(), "emph".into());
}
MarkKind::Underline => {
m.insert("type".into(), "underline".into());
}
MarkKind::Strike => {
m.insert("type".into(), "strike".into());
}
MarkKind::Code => {
m.insert("type".into(), "code".into());
}
MarkKind::Link { url } => {
m.insert("type".into(), "link".into());
m.insert("url".into(), Value::String(url.clone()));
}
MarkKind::Anchor { id } => {
m.insert("type".into(), "anchor".into());
m.insert("id".into(), Value::String(id.clone()));
}
MarkKind::Unknown { tag, attrs } => {
m.insert("type".into(), Value::String(tag.clone()));
m.insert("attrs".into(), attrs.clone());
}
}
Value::Object(m)
}
struct MarkShape<'a> {
fields: &'a Map<String, Value>,
start: Usv,
end: Usv,
ty: &'a str,
}
fn mark_shape(v: &Value) -> Result<MarkShape<'_>, ParseError> {
let fields = v.as_object().ok_or(ParseError::Shape("mark"))?;
let start = usv_from(fields.get("start"), "mark start")?;
let end = usv_from(fields.get("end"), "mark end")?;
let ty = fields
.get("type")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("mark type"))?;
Ok(MarkShape {
fields,
start,
end,
ty,
})
}
pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
let MarkShape {
fields: o,
start,
end,
ty,
} = mark_shape(v)?;
let o = fold_legacy_attrs(o, ty, Content::RESERVED_MARK_TYPES, "mark attrs")?;
let kind = match ty {
"strong" => MarkKind::Strong,
"emph" => MarkKind::Emph,
"underline" => MarkKind::Underline,
"strike" => MarkKind::Strike,
"code" => MarkKind::Code,
"link" => MarkKind::Link {
url: o
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
},
"anchor" => MarkKind::Anchor {
id: o
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
},
other => MarkKind::Unknown {
tag: other.to_string(),
attrs: bag_from_wire(&o, "attrs", "mark attrs")?,
},
};
Ok(Mark { start, end, kind })
}
pub(crate) fn line_kind_from_authored_value(v: &Value) -> Result<LineKind, ParseError> {
reject_line_kind_attrs(v)?;
line_kind_from_value(v)
}
pub(crate) fn container_from_authored_value(v: &Value) -> Result<Container, ParseError> {
reject_container_attrs(v)?;
container_from_value(v)
}
pub(crate) fn mark_from_authored_value(v: &Value) -> Result<Mark, ParseError> {
reject_mark_attrs(v)?;
mark_from_value(v)
}
pub(crate) fn reject_unreadable_mark(v: &Value) -> Result<(), ParseError> {
reject_mark_attrs(v)?;
mark_shape(v)?;
Ok(())
}
pub fn from_authored_value(v: &Value) -> Result<Content, ParseError> {
reject_reserved_attrs_deep(v)?;
from_canonical_value(v)
}
fn reject_reserved_attrs_deep(v: &Value) -> Result<(), ParseError> {
for line in arr_or_empty(v, "lines") {
reject_line_kind_attrs(line)?;
for c in arr_or_empty(line, "containers") {
reject_container_attrs(c)?;
}
}
for m in arr_or_empty(v, "marks") {
reject_mark_attrs(m)?;
}
for island in arr_or_empty(v, "islands") {
let ty = island.get("type").and_then(Value::as_str).unwrap_or_default();
match crate::island::KnownIslandType::parse(ty) {
Some(crate::island::KnownIslandType::Table) => {
let Some(props) = island.get("props") else {
continue;
};
for cell in table_cell_values(props) {
for m in arr_or_empty(cell, "marks") {
reject_unreadable_mark(m)?;
}
}
}
Some(crate::island::KnownIslandType::Image) | None => {}
}
}
Ok(())
}
fn reject_line_kind_attrs(v: &Value) -> Result<(), ParseError> {
reject_reserved_attrs(
v,
"kind",
Content::RESERVED_LINE_KINDS,
"attrs beside built-in kind",
)
}
fn reject_container_attrs(v: &Value) -> Result<(), ParseError> {
reject_reserved_attrs(
v,
"container",
Content::RESERVED_CONTAINERS,
"attrs beside built-in container",
)
}
fn reject_mark_attrs(v: &Value) -> Result<(), ParseError> {
reject_reserved_attrs(
v,
"type",
Content::RESERVED_MARK_TYPES,
"attrs beside built-in mark type",
)
}
fn reject_reserved_attrs(
v: &Value,
discriminant: &str,
reserved: &[&str],
err: &'static str,
) -> Result<(), ParseError> {
let Some(o) = v.as_object() else {
return Ok(());
};
if !o.contains_key("attrs") {
return Ok(());
}
let Some(tag) = o.get(discriminant).and_then(Value::as_str) else {
return Ok(());
};
if reserved.contains(&tag) {
return Err(ParseError::Shape(err));
}
Ok(())
}
pub fn parse_cell(v: &Value) -> (String, Vec<Mark>) {
let text = v
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let marks = v
.get("marks")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(|m| mark_from_value(m).ok()).collect())
.unwrap_or_default();
(text, marks)
}
pub(crate) fn cell_to_value(text: &str, marks: &[Mark]) -> Value {
let mut m = Map::new();
m.insert("text".into(), Value::String(text.to_string()));
m.insert(
"marks".into(),
Value::Array(marks.iter().map(mark_to_value).collect()),
);
Value::Object(m)
}
pub(crate) fn table_cell_values(props: &Value) -> impl Iterator<Item = &Value> {
let header = arr_or_empty(props, "header").iter();
let rows = arr_or_empty(props, "rows")
.iter()
.flat_map(|row| as_slice(row).iter());
header.chain(rows)
}
pub(crate) fn table_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
table_cell_values(props).map(parse_cell).collect()
}
pub(crate) fn normalize_table_props(props: &mut Value) {
let cols = table_cols(props);
let Some(obj) = props.as_object_mut() else {
return;
};
let header = obj.entry("header").or_insert_with(|| Value::Array(vec![]));
if !header.is_array() {
*header = Value::Array(vec![]);
}
pad_row(header, cols);
if let Some(h) = header.as_array_mut() {
h.iter_mut().for_each(canon_cell);
}
let aligns = obj.entry("aligns").or_insert_with(|| Value::Array(vec![]));
if let Some(a) = aligns.as_array_mut() {
while a.len() < cols {
a.push(Value::String("none".into()));
}
}
if let Some(rows) = obj.get_mut("rows").and_then(Value::as_array_mut) {
for row in rows.iter_mut() {
pad_row(row, cols);
if let Some(r) = row.as_array_mut() {
r.iter_mut().for_each(canon_cell);
}
}
}
}
fn table_cols(props: &Value) -> usize {
let arr_len = |k: &str| props.get(k).and_then(Value::as_array).map(|a| a.len());
let header = arr_len("header").unwrap_or(0);
let aligns = arr_len("aligns").unwrap_or(0);
let widest_row = props
.get("rows")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.map(|r| r.as_array().map(|a| a.len()).unwrap_or(0))
.max()
.unwrap_or(0)
})
.unwrap_or(0);
header.max(aligns).max(widest_row)
}
fn pad_row(v: &mut Value, cols: usize) {
if let Some(arr) = v.as_array_mut() {
while arr.len() < cols {
arr.push(cell_to_value("", &[]));
}
}
}
fn canon_cell(cell: &mut Value) {
let (text, marks) = parse_cell(cell);
let text = if text.contains(['\n', '\r']) {
text.replace(['\n', '\r'], " ")
} else {
text
};
let canon = cell_to_value(&text, &crate::model::normalize_marks(marks));
match (cell.as_object_mut(), canon) {
(Some(o), Value::Object(fields)) => o.extend(fields),
(_, canon) => *cell = canon,
}
}
pub(crate) fn table_shape_error(props: &Value) -> Option<Invariant> {
if props.get("header").is_some_and(|h| !h.is_array()) {
return Some(Invariant::TableHeaderNotArray);
}
let cols = props
.get("header")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(0);
let aligns = props
.get("aligns")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(0);
if aligns != cols {
return Some(Invariant::TableAlignsMismatch { aligns, cols });
}
if let Some(rows) = props.get("rows").and_then(Value::as_array) {
for (i, row) in rows.iter().enumerate() {
let width = row.as_array().map(|a| a.len()).unwrap_or(0);
if width != cols {
return Some(Invariant::TableRaggedRow {
row: i,
width,
cols,
});
}
}
}
for (i, (text, _)) in table_cells(props).iter().enumerate() {
if text.contains('\n') || text.contains('\r') {
return Some(Invariant::TableCellNewline { cell: i });
}
}
None
}
fn island_to_value(island: &Island) -> Value {
let mut m = Map::new();
m.insert("id".into(), Value::String(island.id.clone()));
m.insert("type".into(), Value::String(island.island_type.clone()));
m.insert("props".into(), island.props.clone());
m.insert("loss".into(), island.loss.as_str().into());
Value::Object(m)
}
fn island_from_value(v: &Value) -> Result<Island, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("island"))?;
Ok(Island {
id: o
.get("id")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("island id"))?
.to_string(),
island_type: o
.get("type")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("island type"))?
.to_string(),
props: bag_from_wire(o, "props", "island props")?,
loss: o
.get("loss")
.and_then(Value::as_str)
.map_or(Loss::LOSSLESS, Loss::new),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Fidelity, Line, LineKind};
fn sample() -> Content {
Content {
text: "hello world".into(),
lines: vec![Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
}],
marks: vec![
Mark {
start: 6,
end: 11,
kind: MarkKind::Strong,
},
Mark {
start: 0,
end: 5,
kind: MarkKind::Emph,
},
],
islands: vec![],
}
}
#[test]
fn deep_container_nesting_is_rejected_at_decode() {
let containers = vec![r#"{"container":"quote"}"#; 20_000].join(",");
let json = format!(
r#"{{"text":"hi","lines":[{{"kind":"para","containers":[{containers}]}}],"marks":[],"islands":[]}}"#
);
assert!(matches!(
Content::from_canonical_json(&json),
Err(ParseError::Invalid(Invariant::NestingTooDeep { .. }))
));
}
fn nested_arrays(depth: usize) -> Value {
let mut v = Value::Null;
for _ in 0..depth {
v = Value::Array(vec![v]);
}
v
}
#[test]
fn deep_json_payload_is_rejected_at_decode_on_the_value_lane() {
let deep = nested_arrays(1_000);
let cases: [(Value, &'static str); 4] = [
(
serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
"marks":[],"islands":[{"id":"i1","type":"widget","loss":"lossless","props":deep}]}),
"island props",
),
(
serde_json::json!({"text":"x","lines":[{"kind":"para","containers":[]}],
"marks":[{"start":0,"end":1,"type":"sparkle","attrs":deep}],"islands":[]}),
"mark attrs",
),
(
serde_json::json!({"text":"x","lines":[{"kind":"callout","containers":[],"attrs":deep}],
"marks":[],"islands":[]}),
"line attrs",
),
(
serde_json::json!({"text":"x","lines":[{"kind":"para",
"containers":[{"container":"indent","attrs":deep}]}],"marks":[],"islands":[]}),
"container attrs",
),
];
for (v, what) in cases {
assert_eq!(
from_canonical_value(&v),
Err(ParseError::Invalid(Invariant::JsonTooDeep {
what,
max: crate::MAX_JSON_DEPTH,
})),
"{what} accepted a 1 000-deep payload"
);
assert!(matches!(
from_authored_value(&v),
Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
));
}
}
#[test]
fn json_depth_cap_admits_every_storable_payload() {
let content = |props: Value| {
serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
"marks":[],"islands":[{"id":"i1","type":"widget","loss":"lossless","props":props}]})
};
assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH))).is_ok());
assert!(from_canonical_value(&content(nested_arrays(crate::MAX_JSON_DEPTH + 1))).is_err());
let mut storable = 0;
for d in 1..=crate::MAX_JSON_DEPTH + 8 {
let v = content(nested_arrays(d));
if Content::from_canonical_json(&v.to_string()).is_ok() {
storable = d;
assert!(
from_canonical_value(&v).is_ok(),
"the bag cap refused a {d}-deep props the string lane accepts"
);
}
}
assert!(
storable > 0 && storable <= crate::MAX_JSON_DEPTH,
"string lane's deepest storable props was {storable}"
);
}
#[test]
fn deep_json_payload_is_rejected_before_the_legacy_attrs_fold() {
let mut deep = Value::Null;
for _ in 0..1_000 {
deep = serde_json::json!({"a": deep});
}
let v = serde_json::json!({"text":"x","lines":[{"kind":"para","containers":[],"attrs":deep}],
"marks":[],"islands":[]});
assert_eq!(
from_canonical_value(&v),
Err(ParseError::Invalid(Invariant::JsonTooDeep {
what: "line attrs",
max: crate::MAX_JSON_DEPTH,
}))
);
}
#[test]
fn deep_json_payload_is_rejected_on_the_op_wire() {
let deep = nested_arrays(1_000);
let op = serde_json::json!({"op":"add","start":0,"end":1,"type":"sparkle","attrs":deep});
assert!(matches!(
crate::ops::mark_op_from_value(&op),
Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
));
let op = serde_json::json!({"op":"setKind","line":0,"kind":"callout","attrs":deep});
assert!(matches!(
crate::ops::line_op_from_value(&op),
Err(ParseError::Invalid(Invariant::JsonTooDeep { .. }))
));
}
#[test]
fn out_of_range_wire_position_is_refused() {
let json = r#"{"text":"hello","lines":[{"kind":"para","containers":[]}],"marks":[{"start":4294967301,"end":4294967302,"type":"strong"}],"islands":[]}"#;
assert!(Content::from_canonical_json(json).is_err());
assert!(usv_from(Some(&Value::from(u64::MAX)), "x").is_ok() || usize::BITS < 64);
assert!(usv_from(Some(&Value::from(-1i64)), "x").is_err());
}
#[test]
fn island_props_key_order_does_not_leak() {
let mut one = Content::empty();
one.text = "\u{FFFC}".into();
one.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
one.islands = vec![Island {
id: "i1".into(),
island_type: "table".into(),
props: serde_json::json!({"b": 1, "a": 2}),
loss: Loss::LOSSLESS,
}];
let mut two = one.clone();
two.islands[0].props = serde_json::json!({"a": 2, "b": 1}); assert_eq!(one.to_canonical_json(), two.to_canonical_json());
}
#[test]
fn golden_bytes_are_feature_independent() {
let rt = sample();
assert_eq!(
rt.to_canonical_json(),
r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":5,"start":0,"type":"emph"},{"end":11,"start":6,"type":"strong"}],"text":"hello world"}"#
);
}
#[test]
fn from_canonical_json_rejects_invalid() {
let bad =
r#"{"text":"a\nb","lines":[{"kind":"para","containers":[]}],"marks":[],"islands":[]}"#;
assert!(matches!(
Content::from_canonical_json(bad),
Err(ParseError::Invalid(_))
));
}
#[test]
fn reserved_unknown_tag_rejected() {
let mut rt = Content::empty();
rt.text = "abcd".into();
rt.marks = vec![Mark {
start: 0,
end: 4,
kind: MarkKind::Unknown {
tag: "strong".into(),
attrs: serde_json::json!({}),
},
}];
assert!(matches!(
rt.validate(),
Err(crate::model::Invariant::ReservedUnknownTag(_))
));
}
#[test]
fn unknown_loss_class_round_trips_and_reads_unrepresentable() {
let json = concat!(
r#"{"islands":[{"id":"i1","loss":"partial","props":{},"type":"widget"}],"#,
r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":"๏ฟผ"}"#
);
let rt = Content::from_canonical_json(json).unwrap();
assert_eq!(rt.islands[0].loss, Loss::new("partial"));
assert_eq!(rt.islands[0].loss.fidelity(), Fidelity::Unrepresentable);
assert_eq!(rt.to_canonical_json(), json);
}
#[test]
fn a_built_in_class_name_has_one_spelling() {
assert_eq!(Loss::new("lossless"), Loss::LOSSLESS);
let mut rt = Content::empty();
rt.text = "\u{FFFC}".into();
rt.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
rt.islands = vec![Island {
id: "i1".into(),
island_type: "widget".into(),
props: serde_json::json!({}),
loss: Loss::new("lossless"),
}];
assert_eq!(rt.validate(), Ok(()));
let back = Content::from_canonical_json(&rt.to_canonical_json()).unwrap();
assert_eq!(back.islands[0].loss, rt.islands[0].loss);
assert_eq!(back.islands[0].loss.fidelity(), Fidelity::Lossless);
}
#[test]
fn every_fidelity_level_round_trips_through_its_class() {
for &f in Fidelity::ALL {
assert_eq!(Loss::new(f.as_str()).fidelity(), f);
}
}
#[test]
fn unknown_line_kind_and_container_round_trip_opaque() {
let json = concat!(
r#"{"islands":[],"lines":[{"attrs":{"variant":"warn"},"containers":"#,
r#"[{"attrs":{"depth":2},"container":"indent"}],"kind":"callout"}],"#,
r#""marks":[],"text":"heads up"}"#
);
let rt = Content::from_canonical_json(json).unwrap();
assert_eq!(
rt.lines[0].kind,
LineKind::Unknown {
tag: "callout".into(),
attrs: serde_json::json!({"variant": "warn"}),
}
);
assert_eq!(
rt.lines[0].containers,
vec![Container::Unknown {
tag: "indent".into(),
attrs: serde_json::json!({"depth": 2}),
}]
);
assert_eq!(rt.to_canonical_json(), json);
let bare = r#"{"islands":[],"lines":[{"containers":[],"kind":"footnote"}],"marks":[],"text":"x"}"#;
let rt = Content::from_canonical_json(bare).unwrap();
assert_eq!(
rt.lines[0].kind,
LineKind::Unknown {
tag: "footnote".into(),
attrs: Value::Null,
}
);
for bad in [
r#"{"islands":[],"lines":[{"containers":[]}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[{"container":7}],"kind":"para"}],"marks":[],"text":"x"}"#,
] {
assert!(matches!(
Content::from_canonical_json(bad),
Err(ParseError::Shape(_))
));
}
}
#[test]
fn reserved_block_vocabulary_names_rejected() {
let mut rt = Content::empty();
rt.text = "abcd".into();
rt.lines[0].kind = LineKind::Unknown {
tag: "heading".into(),
attrs: serde_json::json!({}),
};
assert_eq!(
rt.validate(),
Err(Invariant::ReservedUnknownLineKind("heading".into()))
);
rt.lines[0].kind = LineKind::Para;
rt.lines[0].containers = vec![Container::Unknown {
tag: "quote".into(),
attrs: serde_json::json!({}),
}];
assert_eq!(
rt.validate(),
Err(Invariant::ReservedUnknownContainer("quote".into()))
);
}
#[test]
fn authored_lane_rejects_attrs_beside_a_built_in_name() {
let bad = [
r#"{"islands":[],"lines":[{"attrs":{"tone":"warn"},"containers":[],"kind":"para"}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[{"attrs":{},"container":"quote"}],"kind":"para"}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"attrs":{},"end":1,"start":0,"type":"strong"}],"text":"x"}"#,
concat!(
r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
r#""header":[{"marks":[{"attrs":{},"end":1,"start":0,"type":"emph"}],"text":"h"}],"#,
r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":"๏ฟผ"}"#
),
concat!(
r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
r#""header":[{"marks":[{"end":1,"start":0}],"text":"h"}],"#,
r#""rows":[[{"marks":[],"text":"r"}]]},"type":"table"}],"#,
r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":"๏ฟผ"}"#
),
];
for json in bad {
let v: Value = serde_json::from_str(json).unwrap();
assert!(
matches!(from_authored_value(&v), Err(ParseError::Shape(_))),
"accepted: {json}"
);
assert!(
Content::from_canonical_json(json).is_ok(),
"storage lane rejected: {json}"
);
}
let rt = Content::from_canonical_json(bad[4]).unwrap();
assert!(rt.islands[0].props["header"][0]["marks"]
.as_array()
.unwrap()
.is_empty());
}
#[test]
fn authored_lane_leaves_opaque_attrs_payload_alone() {
let json = concat!(
r#"{"islands":[],"lines":[{"attrs":{"nested":{"attrs":{},"type":"link"}},"#,
r#""containers":[],"kind":"callout"}],"marks":[],"text":"x"}"#
);
let v: Value = serde_json::from_str(json).unwrap();
let rt = from_authored_value(&v).unwrap();
assert_eq!(rt.to_canonical_json(), json);
}
#[test]
fn unknown_block_attrs_key_order_does_not_leak() {
let mut one = Content::empty();
one.text = "hi".into();
one.lines[0].kind = LineKind::Unknown {
tag: "callout".into(),
attrs: serde_json::json!({"b": 1, "a": 2}),
};
one.lines[0].containers = vec![Container::Unknown {
tag: "indent".into(),
attrs: serde_json::json!({"y": 1, "x": 2}),
}];
let mut two = one.clone();
two.lines[0].kind = LineKind::Unknown {
tag: "callout".into(),
attrs: serde_json::json!({"a": 2, "b": 1}),
};
two.lines[0].containers = vec![Container::Unknown {
tag: "indent".into(),
attrs: serde_json::json!({"x": 2, "y": 1}),
}];
assert_eq!(one.to_canonical_json(), two.to_canonical_json());
one.normalize();
two.normalize();
assert_eq!(one, two, "normalize canonicalizes the live model too");
}
#[test]
fn unknown_mark_round_trips_opaque() {
let mut rt = Content::empty();
rt.text = "abcd".into();
rt.marks = vec![Mark {
start: 0,
end: 4,
kind: MarkKind::Unknown {
tag: "highlight".into(),
attrs: serde_json::json!({"color": "yellow"}),
},
}];
let json = rt.to_canonical_json();
let back = Content::from_canonical_json(&json).unwrap();
assert_eq!(back.marks[0].kind, rt.marks[0].kind);
}
#[test]
fn built_in_decoders_read_the_legacy_attrs_form() {
let cases: [(Value, LineKind); 2] = [
(
serde_json::json!({"kind": "heading", "attrs": {"level": 2}}),
LineKind::Heading { level: 2 },
),
(
serde_json::json!({"kind": "code", "attrs": {"lang": "rust"}}),
LineKind::Code {
lang: Some("rust".into()),
},
),
];
for (v, want) in cases {
assert_eq!(line_kind_from_value(&v).unwrap(), want);
}
let item = serde_json::json!({
"container": "list_item",
"attrs": {"ordered": true, "start": 3, "ordinal": 1}
});
assert_eq!(
container_from_value(&item).unwrap(),
Container::ListItem {
ordered: true,
start: 3,
ordinal: 1,
}
);
let link = serde_json::json!({"start": 0, "end": 1, "type": "link", "attrs": {"url": "u"}});
assert_eq!(
mark_from_value(&link).unwrap().kind,
MarkKind::Link { url: "u".into() }
);
let both = serde_json::json!({"kind": "heading", "level": 3, "attrs": {"level": 2}});
assert_eq!(
line_kind_from_value(&both).unwrap(),
LineKind::Heading { level: 3 }
);
let unknown = serde_json::json!({"kind": "callout", "attrs": {"kind": "heading", "level": 2}});
assert_eq!(
line_kind_from_value(&unknown).unwrap(),
LineKind::Unknown {
tag: "callout".into(),
attrs: serde_json::json!({"kind": "heading", "level": 2}),
}
);
let legacy = r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":[],"kind":"heading"}],"marks":[],"text":"hi"}"#;
assert_eq!(
Content::from_canonical_json(legacy)
.unwrap()
.to_canonical_json(),
r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"hi"}"#
);
}
#[test]
fn unknown_holds_the_last_mark_ordinal() {
let all = [
MarkKind::Strong,
MarkKind::Emph,
MarkKind::Underline,
MarkKind::Strike,
MarkKind::Code,
MarkKind::Link { url: "u".into() },
MarkKind::Anchor { id: "a".into() },
MarkKind::Unknown {
tag: "kbd".into(),
attrs: Value::Null,
},
];
for k in &all {
match k {
MarkKind::Strong
| MarkKind::Emph
| MarkKind::Underline
| MarkKind::Strike
| MarkKind::Code
| MarkKind::Link { .. }
| MarkKind::Anchor { .. }
| MarkKind::Unknown { .. } => {}
}
}
let ords: Vec<u8> = all.iter().map(MarkKind::ord).collect();
assert_eq!(ords, (0..all.len() as u8).collect::<Vec<_>>());
assert!(matches!(all.last(), Some(MarkKind::Unknown { .. })));
}
#[test]
fn formatting_class_membership_decides_adjacent_union() {
let mut rt = Content::empty();
rt.text = "abcd".into();
let unknown = |start, end| Mark {
start,
end,
kind: MarkKind::Unknown {
tag: "kbd".into(),
attrs: serde_json::json!({}),
},
};
rt.marks = vec![unknown(0, 2), unknown(2, 4)];
rt.normalize();
assert_eq!(rt.marks.len(), 2);
rt.marks = vec![
Mark {
start: 0,
end: 2,
kind: MarkKind::Strong,
},
Mark {
start: 2,
end: 4,
kind: MarkKind::Strong,
},
];
rt.normalize();
assert_eq!(rt.marks.len(), 1);
}
#[test]
fn reserved_growth_flips_authored_acceptance() {
let doc = |kind: &str| {
serde_json::json!({
"islands": [],
"lines": [{"attrs": {"level": 2}, "containers": [], "kind": kind}],
"marks": [],
"text": "hi",
})
};
assert!(from_authored_value(&doc("callout")).is_ok());
assert!(matches!(
from_authored_value(&doc("heading")),
Err(ParseError::Shape(_))
));
}
}