use crate::model::{
canonicalize_keys, Container, Island, Line, LineKind, Loss, Mark,
MarkKind, Content, Normalized, Usv,
};
use serde_json::{Map, Value};
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Shape(&'static str),
Json(String),
Invalid(crate::model::Invariant),
UnknownName {
axis: &'static str,
name: String,
},
}
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:?}"),
ParseError::UnknownName { axis, name } => {
write!(f, "content vocabulary: unknown {axis} {name:?}")
}
}
}
}
impl std::error::Error for ParseError {}
impl Normalized {
pub fn to_canonical_json(&self) -> String {
to_canonical_value(self).to_string()
}
}
impl Content {
pub fn from_canonical_json(s: &str) -> Result<Normalized, 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(
"islands".into(),
Value::Array(self.islands.iter().map(island_to_value).collect()),
);
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("text".into(), Value::String(self.text.clone()));
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: &Normalized) -> Value {
let mut v = rt.to_value();
canonicalize_keys(&mut v);
v
}
fn sort_own_keys(m: Map<String, Value>) -> Map<String, Value> {
let mut entries: Vec<(String, Value)> = m.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries.into_iter().collect()
}
pub fn from_canonical_value(v: &Value) -> Result<Normalized, ParseError> {
seal(Content::from_value(v)?)
}
fn seal(rt: Content) -> Result<Normalized, ParseError> {
let rt = rt.into_normalized();
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 legacy_line_kind_keys(tag: &str) -> &'static [&'static str] {
match tag {
"heading" => &["level"],
"code" => &["lang"],
_ => &[],
}
}
fn legacy_container_keys(tag: &str) -> &'static [&'static str] {
match tag {
"list_item" => &["ordered", "ordinal", "start"],
_ => &[],
}
}
fn legacy_mark_keys(ty: &str) -> &'static [&'static str] {
match ty {
"link" => &["url"],
"anchor" => &["id"],
_ => &[],
}
}
fn payload<'a>(o: &'a Map<String, Value>, legacy: &[&str], key: &'static str) -> Option<&'a Value> {
match o.get("attrs").filter(|a| !crate::model::is_empty_bag(a)) {
Some(attrs) => attrs.get(key),
None if legacy.contains(&key) => o.get(key),
None => None,
}
}
fn insert_attrs(m: &mut Map<String, Value>, attrs: Cow<'_, Value>) {
if !crate::model::is_empty_bag(&attrs) {
m.insert("attrs".into(), attrs.into_owned());
}
}
pub fn line_kind_to_value(kind: &LineKind) -> Value {
Value::Object(line_kind_fields(kind))
}
fn line_kind_fields(kind: &LineKind) -> Map<String, Value> {
let mut m = Map::new();
insert_attrs(&mut m, kind.attrs());
m.insert("kind".into(), Value::String(kind.tag().to_string()));
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 legacy = legacy_line_kind_keys(tag);
match tag {
"para" => Ok(LineKind::Para),
"heading" => {
let level = payload(o, legacy, "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: payload(o, legacy, "lang")
.and_then(Value::as_str)
.map(crate::import::sanitize_lang)
.filter(|l| !l.is_empty()),
}),
"island" => Ok(LineKind::Island),
"rule" => Ok(LineKind::Rule),
other => Err(ParseError::UnknownName {
axis: "line kind",
name: other.to_string(),
}),
}
}
fn line_to_value(line: &Line) -> Value {
let mut m = line_kind_fields(&line.kind);
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(sort_own_keys(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();
insert_attrs(&mut m, c.attrs());
m.insert("container".into(), Value::String(c.tag().to_string()));
if c.instance() != 0 {
m.insert("instance".into(), Value::from(c.instance()));
}
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 instance = o.get("instance").and_then(Value::as_u64).unwrap_or(0);
let legacy = legacy_container_keys(tag);
match tag {
"list_item" => Ok(Container::ListItem {
ordered: payload(o, legacy, "ordered")
.and_then(Value::as_bool)
.unwrap_or(false),
start: payload(o, legacy, "start")
.and_then(Value::as_u64)
.unwrap_or(1),
ordinal: payload(o, legacy, "ordinal")
.and_then(Value::as_u64)
.unwrap_or(0),
instance,
}),
"quote" => Ok(Container::Quote { instance }),
other => Err(ParseError::UnknownName {
axis: "container",
name: other.to_string(),
}),
}
}
pub fn mark_to_value(mark: &Mark) -> Value {
let mut m = Map::new();
insert_attrs(&mut m, mark.kind.attrs());
m.insert("end".into(), Value::from(mark.end));
m.insert("start".into(), Value::from(mark.start));
m.insert("type".into(), Value::String(mark.kind.tag().to_string()));
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 legacy = legacy_mark_keys(ty);
let kind = match ty {
"strong" => MarkKind::Strong,
"emph" => MarkKind::Emph,
"underline" => MarkKind::Underline,
"strike" => MarkKind::Strike,
"code" => MarkKind::Code,
"link" => MarkKind::Link {
url: payload(o, legacy, "url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
},
"anchor" => MarkKind::Anchor {
id: payload(o, legacy, "id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
},
other => {
return Err(ParseError::UnknownName {
axis: "mark type",
name: other.to_string(),
})
}
};
Ok(Mark { start, end, kind })
}
pub(crate) fn line_kind_from_authored_value(v: &Value) -> Result<LineKind, ParseError> {
reject_line_kind_legacy(v)?;
reject_unwritable_lang(v)?;
line_kind_from_value(v)
}
fn reject_unwritable_lang(v: &Value) -> Result<(), ParseError> {
let Some(o) = v.as_object() else {
return Ok(());
};
if o.get("kind").and_then(Value::as_str) != Some("code") {
return Ok(());
}
let Some(lang) = payload(o, legacy_line_kind_keys("code"), "lang").and_then(Value::as_str)
else {
return Ok(());
};
if crate::import::sanitize_lang(lang) != lang {
return Err(ParseError::Shape("code lang"));
}
Ok(())
}
pub(crate) fn container_from_authored_value(v: &Value) -> Result<Container, ParseError> {
reject_container_legacy(v)?;
container_from_value(v)
}
pub(crate) fn mark_from_authored_value(v: &Value) -> Result<Mark, ParseError> {
reject_mark_legacy(v)?;
mark_from_value(v)
}
pub(crate) fn reject_unreadable_mark(v: &Value) -> Result<(), ParseError> {
reject_mark_legacy(v)?;
reject_unwritable_link_url(v)?;
mark_from_value(v)?;
Ok(())
}
pub(crate) fn island_from_authored_value(v: &Value) -> Result<Island, ParseError> {
reject_unwritable_image_url(v)?;
island_from_value(v)
}
pub(crate) fn reject_unwritable_link_url(v: &Value) -> Result<(), ParseError> {
let Some(o) = v.as_object() else {
return Ok(());
};
if o.get("type").and_then(Value::as_str) != Some("link") {
return Ok(());
}
reject_unwritable_url(
payload(o, legacy_mark_keys("link"), "url").and_then(Value::as_str),
"link url",
)
}
fn reject_unwritable_image_url(v: &Value) -> Result<(), ParseError> {
let image = crate::island::IslandType::Image.as_str();
if v.get("type").and_then(Value::as_str) != Some(image) {
return Ok(());
}
reject_unwritable_url(
v.get("props")
.and_then(|p| p.get("url"))
.and_then(Value::as_str),
"image url",
)
}
fn reject_unwritable_url(url: Option<&str>, err: &'static str) -> Result<(), ParseError> {
match url {
Some(u) if !crate::export::url_is_writable(u) => Err(ParseError::Shape(err)),
_ => Ok(()),
}
}
pub fn from_authored_value(v: &Value) -> Result<Normalized, ParseError> {
authored_lane_scan(v)?;
let rt = Content::from_value(v)?;
reject_inline_block_island(&rt)?;
seal(rt)
}
fn reject_inline_block_island(rt: &Content) -> Result<(), ParseError> {
let chars: Vec<char> = rt.text.chars().collect();
match crate::model::inline_block_islands(&chars, &rt.islands).next() {
Some(_) => Err(ParseError::Shape("block island in a line's prose")),
None => Ok(()),
}
}
fn authored_lane_scan(v: &Value) -> Result<(), ParseError> {
for line in arr_or_empty(v, "lines") {
reject_line_kind_legacy(line)?;
for c in arr_or_empty(line, "containers") {
reject_container_legacy(c)?;
}
}
for m in arr_or_empty(v, "marks") {
reject_mark_legacy(m)?;
reject_unwritable_link_url(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::IslandType::parse(ty) {
Some(crate::island::IslandType::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::IslandType::Image) => reject_unwritable_image_url(island)?,
None => {}
}
}
Ok(())
}
fn reject_line_kind_legacy(v: &Value) -> Result<(), ParseError> {
reject_legacy_siblings(v, "kind", legacy_line_kind_keys, "legacy kind payload")
}
fn reject_container_legacy(v: &Value) -> Result<(), ParseError> {
reject_legacy_siblings(
v,
"container",
legacy_container_keys,
"legacy container payload",
)
}
fn reject_mark_legacy(v: &Value) -> Result<(), ParseError> {
reject_legacy_siblings(v, "type", legacy_mark_keys, "legacy mark payload")
}
fn reject_legacy_siblings(
v: &Value,
discriminant: &str,
legacy: fn(&str) -> &'static [&'static str],
err: &'static str,
) -> Result<(), ParseError> {
let Some(o) = v.as_object() else {
return Ok(());
};
let Some(tag) = o.get(discriminant).and_then(Value::as_str) else {
return Ok(());
};
if legacy(tag).iter().any(|k| o.contains_key(*k)) {
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(
"marks".into(),
Value::Array(marks.iter().map(mark_to_value).collect()),
);
m.insert("text".into(), Value::String(text.to_string()));
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 !aligns.is_array() {
*aligns = 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() {
if !row.is_array() {
*row = Value::Array(vec![]);
}
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 is_cell_break(c: char) -> bool {
c == '\n' || c == '\r' || crate::normalize::is_line_separator(c)
}
fn canon_cell(cell: &mut Value) {
let (text, marks) = parse_cell(cell);
let text = if text.contains(is_cell_break) {
text.replace(is_cell_break, " ")
} 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 island_to_value(island: &Island) -> Value {
let mut m = Map::new();
m.insert("id".into(), Value::String(island.id.clone()));
m.insert("loss".into(), island.loss.as_str().into());
m.insert("props".into(), island.props.clone());
m.insert("type".into(), Value::String(island.island_type.as_str().into()));
Value::Object(m)
}
pub(crate) fn island_from_value(v: &Value) -> Result<Island, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("island"))?;
let name = o
.get("type")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("island type"))?;
let island_type =
crate::island::IslandType::parse(name).ok_or_else(|| ParseError::UnknownName {
axis: "island type",
name: name.to_string(),
})?;
let props = bag_from_wire(o, "props", "island props")?;
island_type.reject_unknown_cell_mark(&props)?;
Ok(Island {
id: o
.get("id")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("island id"))?
.to_string(),
island_type,
props,
loss: match o.get("loss") {
None => Loss::Lossless,
Some(Value::String(name)) => Loss::parse(name)
.ok_or_else(|| ParseError::UnknownName {
axis: "island loss",
name: name.clone(),
})?,
Some(_) => return Err(ParseError::Shape("island loss")),
},
})
}
pub(crate) fn reject_unknown_cell_mark_name(props: &Value) -> Result<(), ParseError> {
for cell in table_cell_values(props) {
for m in arr_or_empty(cell, "marks") {
if let Err(e @ ParseError::UnknownName { .. }) = mark_from_value(m) {
return Err(e);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn instance_is_written_only_where_it_works_and_a_spelled_zero_still_decodes() {
let canonical = r#"{"islands":[],"lines":[{"containers":[{"container":"quote"}],"kind":"para"}],"marks":[],"text":"a"}"#;
let spelled = r#"{"islands":[],"lines":[{"containers":[{"container":"quote","instance":0}],"kind":"para"}],"marks":[],"text":"a"}"#;
let rt = Content::from_canonical_json(canonical).expect("decodes");
assert_eq!(rt.to_canonical_json(), canonical);
assert_eq!(Content::from_canonical_json(spelled).expect("decodes"), rt);
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"}"#;
let rt = Content::from_canonical_json(two).expect("decodes");
assert_eq!(rt.to_canonical_json(), two, "byte layout moved");
}
use super::*;
use crate::island::IslandType;
use crate::model::{Invariant, Line, LineKind, Loss};
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); 1] = [(
serde_json::json!({"text":"\u{fffc}","lines":[{"kind":"island","containers":[]}],
"marks":[],"islands":[{"id":"i1","type":"image","loss":"lossless","props":deep}]}),
"island props",
)];
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":"image","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 a_deep_bag_no_arm_reads_is_dropped_rather_than_refused() {
let mut deep = Value::Null;
for _ in 0..1_000 {
deep = serde_json::json!({"a": deep});
}
let line = |kind: &str| {
serde_json::json!({"text":"x","lines":[{"kind":kind,"containers":[],"attrs":deep}],
"marks":[],"islands":[]})
};
assert!(from_canonical_value(&line("para")).is_ok());
}
#[test]
fn deep_json_payload_is_rejected_on_the_op_wire() {
let deep = nested_arrays(1_000);
let op = serde_json::json!({"op":"insert","at":0,
"id":"i1","type":"image","loss":"lossless","props":deep});
assert!(matches!(
crate::ops::island_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: IslandType::Table,
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.into_normalized().to_canonical_json(),
two.into_normalized().to_canonical_json()
);
}
#[test]
fn encoders_emit_keys_in_ascending_order() {
use crate::model::is_value_key_sorted;
let bag = || serde_json::json!({"a": 1, "b": 2});
let sorted = |v: &Value| is_value_key_sorted(v);
let kinds = [
MarkKind::Strong,
MarkKind::Emph,
MarkKind::Underline,
MarkKind::Strike,
MarkKind::Code,
MarkKind::Link { url: "u".into() },
MarkKind::Anchor { id: "a".into() },
];
let marks: Vec<Mark> = kinds
.iter()
.map(|kind| Mark {
start: 0,
end: 1,
kind: kind.clone(),
})
.collect();
for m in &marks {
assert!(sorted(&mark_to_value(m)), "mark {:?}", m.kind);
}
assert!(sorted(&cell_to_value("t", &marks)));
let containers = vec![
Container::ListItem {
ordered: true,
start: 3,
ordinal: 1,
instance: 0,
},
Container::Quote { instance: 0 },
];
for c in &containers {
assert!(sorted(&container_to_value(c)), "container {c:?}");
}
let line_kinds = [
LineKind::Para,
LineKind::Heading { level: 2 },
LineKind::Code {
lang: Some("rust".into()),
},
LineKind::Code { lang: None },
LineKind::Island,
LineKind::Rule,
];
for kind in line_kinds {
for continues in [false, true] {
let line = Line {
kind: kind.clone(),
containers: containers.clone(),
continues,
};
assert!(sorted(&line_to_value(&line)), "line {:?}", line.kind);
}
}
for &island_type in IslandType::ALL {
let island = Island {
id: "i1".into(),
island_type,
props: bag(),
loss: Loss::Lossless,
};
assert!(
sorted(&island_to_value(&island)),
"island {}",
island_type.as_str()
);
}
}
#[test]
fn the_canonical_tree_needs_no_repair() {
use crate::model::is_value_key_sorted;
let mut rt = Content::empty();
rt.text = "hi\n\u{FFFC}".into();
rt.lines = vec![
Line {
kind: LineKind::Heading { level: 2 },
containers: vec![Container::Quote { instance: 0 }],
continues: false,
},
Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
},
];
rt.marks = vec![Mark {
start: 0,
end: 2,
kind: MarkKind::Link { url: "u".into() },
}];
rt.islands = vec![Island {
id: "i1".into(),
island_type: IslandType::Table,
props: serde_json::json!({
"header": [{"text": "h", "marks": [{"start": 0, "end": 1, "type": "emph"}]}],
"rows": [[{"text": "r", "marks": []}]],
"aligns": ["none"],
}),
loss: Loss::Lossless,
}];
rt.normalize();
assert_eq!(rt.validate(), Ok(()));
assert!(is_value_key_sorted(&rt.to_value()));
}
#[test]
fn golden_bytes_are_feature_independent() {
let rt = sample().into_normalized();
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"}"#
);
let mut rt = Content::empty();
rt.text = "hi".into();
rt.lines = vec![Line {
kind: LineKind::Heading { level: 2 },
containers: vec![Container::ListItem {
ordered: true,
start: 3,
ordinal: 0,
instance: 0,
}],
continues: false,
}];
rt.marks = vec![Mark {
start: 0,
end: 2,
kind: MarkKind::Link { url: "u".into() },
}];
assert_eq!(
rt.into_normalized().to_canonical_json(),
concat!(
r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":"#,
r#"[{"attrs":{"ordered":true,"ordinal":0,"start":3},"container":"list_item"}],"#,
r#""kind":"heading"}],"marks":[{"attrs":{"url":"u"},"end":2,"start":0,"type":"link"}],"#,
r#""text":"hi"}"#
)
);
}
#[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 an_unknown_name_is_refused_on_every_axis_and_both_lanes() {
let doc = |islands: &str, lines: &str, marks: &str, text: &str| {
format!(r#"{{"islands":[{islands}],"lines":[{lines}],"marks":[{marks}],"text":"{text}"}}"#)
};
let cases = [
(
"line kind",
"callout",
doc("", r#"{"containers":[],"kind":"callout"}"#, "", "hi"),
),
(
"container",
"indent",
doc(
"",
r#"{"containers":[{"container":"indent","instance":0}],"kind":"para"}"#,
"",
"hi",
),
),
(
"mark type",
"highlight",
doc(
"",
r#"{"containers":[],"kind":"para"}"#,
r#"{"end":2,"start":0,"type":"highlight"}"#,
"hi",
),
),
(
"island type",
"widget",
doc(
r#"{"id":"i1","loss":"lossless","props":{},"type":"widget"}"#,
r#"{"containers":[],"kind":"island"}"#,
"",
"\u{fffc}",
),
),
(
"island loss",
"partial",
doc(
r#"{"id":"i1","loss":"partial","props":{},"type":"table"}"#,
r#"{"containers":[],"kind":"island"}"#,
"",
"\u{fffc}",
),
),
];
for (axis, name, json) in cases {
let v: Value = serde_json::from_str(&json).unwrap();
for (lane, got) in [
("storage", from_canonical_value(&v)),
("authored", from_authored_value(&v)),
] {
assert_eq!(
got.unwrap_err(),
ParseError::UnknownName {
axis,
name: name.to_string()
},
"{lane} lane accepted {axis} {name:?}"
);
}
}
}
#[test]
fn an_unknown_cell_mark_name_is_refused_rather_than_dropped() {
let cell_marks = |marks: &str| {
format!(
concat!(
r#"{{"islands":[{{"id":"i1","loss":"lossless","props":{{"aligns":["none"],"#,
r#""header":[{{"marks":[{marks}],"text":"h"}}],"#,
r#""rows":[[{{"marks":[],"text":"c"}}]]}},"type":"table"}}],"#,
"\"lines\":[{{\"containers\":[],\"kind\":\"island\"}}],\"marks\":[],\"text\":\"\u{fffc}\"}}"
),
marks = marks
)
};
let outside = cell_marks(r#"{"end":1,"start":0,"type":"highlight"}"#);
let v: Value = serde_json::from_str(&outside).unwrap();
for (lane, got) in [
("storage", from_canonical_value(&v)),
("authored", from_authored_value(&v)),
] {
assert_eq!(
got.unwrap_err(),
ParseError::UnknownName {
axis: "mark type",
name: "highlight".to_string()
},
"{lane} lane accepted a cell mark type outside the vocabulary"
);
}
let malformed = cell_marks(r#"{"end":"x","start":0,"type":"strong"}"#);
let v: Value = serde_json::from_str(&malformed).unwrap();
assert!(from_canonical_value(&v).is_ok());
}
#[test]
fn a_malformed_discriminator_is_a_shape_error() {
for bad in [
r#"{"islands":[],"lines":[{"containers":[]}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[{"container":7}],"kind":"para"}],"marks":[],"text":"x"}"#,
"{\"islands\":[{\"id\":\"i\",\"loss\":7,\"props\":{},\"type\":\"table\"}],\"lines\":[{\"containers\":[],\"kind\":\"island\"}],\"marks\":[],\"text\":\"\u{fffc}\"}",
] {
assert!(
matches!(
Content::from_canonical_json(bad),
Err(ParseError::Shape(_))
),
"not a shape error: {bad}"
);
}
}
#[test]
fn every_fidelity_level_round_trips_through_its_class() {
for &l in Loss::ALL {
assert_eq!(Loss::parse(l.as_str()), Some(l));
}
}
#[test]
fn authored_lane_rejects_the_legacy_payload_spelling() {
let bad = [
r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[{"container":"list_item","ordered":true}],"kind":"para"}],"marks":[],"text":"x"}"#,
r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"marks":[{"end":1,"start":0,"type":"link","url":"u"}],"text":"x"}"#,
concat!(
r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
r#""header":[{"marks":[{"end":1,"start":0,"type":"link","url":"u"}],"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 a_foreign_bag_on_a_built_in_drops_unread() {
let json = concat!(
r#"{"islands":[],"lines":[{"attrs":{"tone":"warn"},"containers":"#,
r#"[{"attrs":{"x":1},"container":"quote"}],"kind":"para"}],"#,
r#""marks":[{"attrs":{"y":2},"end":1,"start":0,"type":"strong"}],"text":"x"}"#
);
let v: Value = serde_json::from_str(json).unwrap();
let rt = from_authored_value(&v).expect("authored lane accepts");
assert_eq!(rt.lines[0].kind, LineKind::Para);
assert_eq!(rt.marks[0].kind, MarkKind::Strong);
assert_eq!(
Content::from_canonical_json(json).expect("storage lane accepts"),
rt
);
assert_eq!(
rt.to_canonical_json(),
r#"{"islands":[],"lines":[{"containers":[{"container":"quote"}],"kind":"para"}],"marks":[{"end":1,"start":0,"type":"strong"}],"text":"x"}"#
);
}
#[test]
fn authored_lane_leaves_opaque_props_payload_alone() {
let json = concat!(
r#"{"islands":[{"id":"i1","loss":"lossless","props":{"aligns":["none"],"#,
r#""header":[{"marks":[],"text":"h"}],"note":{"type":"link","url":"a\nb"},"#,
r#""rows":[[{"marks":[],"text":"c"}]]},"type":"table"}],"#,
r#""lines":[{"containers":[],"kind":"island"}],"marks":[],"text":""}"#
);
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 decoded_code_lang_carries_the_sanitized_shape() {
let cases: [(Value, Option<&str>); 5] = [
(
serde_json::json!({"kind": "code", "attrs": {"lang": "rust\ninjected line"}}),
Some("rust"),
),
(
serde_json::json!({"kind": "code", "attrs": {"lang": "r`s"}}),
Some("r"),
),
(
serde_json::json!({"kind": "code", "attrs": {"lang": " rust"}}),
None,
),
(
serde_json::json!({"kind": "code", "attrs": {"lang": "c++ 17"}}),
Some("c++"),
),
(
serde_json::json!({"kind": "code", "lang": "r`s"}),
Some("r"),
),
];
for (v, want) in cases {
assert_eq!(
line_kind_from_value(&v).unwrap(),
LineKind::Code {
lang: want.map(str::to_string)
},
"{v}"
);
}
}
#[test]
fn an_unwritable_code_lang_sanitizes_on_storage_and_is_refused_when_authored() {
let v = serde_json::json!({"kind": "code", "attrs": {"lang": "rust\ninjected line"}});
assert_eq!(
line_kind_from_value(&v).unwrap(),
LineKind::Code {
lang: Some("rust".to_string())
}
);
assert!(matches!(
line_kind_from_authored_value(&v),
Err(ParseError::Shape("code lang"))
));
}
#[test]
fn an_unwritable_url_decodes_on_storage_and_is_refused_when_authored() {
let mark =
serde_json::json!({"type": "link", "start": 0, "end": 1, "attrs": {"url": "a\nb"}});
assert_eq!(
mark_from_value(&mark).unwrap().kind,
MarkKind::Link { url: "a\nb".into() }
);
assert!(matches!(
reject_unwritable_link_url(&mark),
Err(ParseError::Shape("link url"))
));
let island =
serde_json::json!({"id": "i1", "type": "image", "props": {"alt": "a", "url": "u\rv"}});
assert_eq!(island_from_value(&island).unwrap().props["url"], "u\rv");
assert!(matches!(
island_from_authored_value(&island),
Err(ParseError::Shape("image url"))
));
}
#[test]
fn the_authored_content_door_refuses_an_unwritable_url() {
for json in [
concat!(
r#"{"islands":[],"lines":[{"containers":[],"kind":"para"}],"#,
r#""marks":[{"attrs":{"url":"a\nb"},"end":1,"start":0,"type":"link"}],"text":"x"}"#
),
concat!(
r#"{"islands":[{"id":"i1","loss":"lossless","props":{"alt":"a","url":"u\nv"},"#,
r#""type":"image"}],"lines":[{"containers":[],"kind":"para"}],"marks":[],"text":""}"#
),
] {
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}"
);
}
}
#[test]
fn an_inline_block_island_splits_on_storage_and_is_refused_when_authored() {
let slot = crate::model::ISLAND_SLOT;
let content = |island_type: &str, props: &str| {
format!(
concat!(
r#"{{"islands":[{{"id":"isl-0","loss":"lossless","props":{},"#,
r#""type":"{}"}}],"lines":[{{"containers":[],"kind":"para"}}],"#,
r#""marks":[],"text":"ab"}}"#
),
props, island_type
)
};
let table = content(
"table",
r#"{"aligns":["none"],"header":[{"marks":[],"text":"h"}],"rows":[[{"marks":[],"text":"c"}]]}"#,
);
let rt = Content::from_canonical_json(&table).expect("storage lane accepts");
assert_eq!(rt.text, format!("a\n{slot}\nb"), "not split at the load");
assert_eq!(rt.lines[1].kind, LineKind::Island);
let v: Value = serde_json::from_str(&table).unwrap();
assert!(matches!(from_authored_value(&v), Err(ParseError::Shape(_))));
let image = content("image", r#"{"alt":"a","url":"u"}"#);
let rt = Content::from_canonical_json(&image).expect("storage lane accepts");
assert_eq!(rt.text, format!("a{slot}b"), "inline island moved");
let v: Value = serde_json::from_str(&image).unwrap();
assert!(from_authored_value(&v).is_ok(), "inline island refused");
}
#[test]
fn a_stored_inline_block_island_survives_the_markdown_round_trip() {
let slot = crate::model::ISLAND_SLOT;
let json = concat!(
r#"{"islands":[{"id":"isl-0","loss":"lossless","props":{"aligns":["none"],"#,
r#""header":[{"marks":[],"text":"h"}],"rows":[[{"marks":[],"text":"c"}]]},"#,
r#""type":"table"}],"lines":[{"containers":[],"kind":"para"}],"#,
r#""marks":[{"end":6,"start":2,"type":"strong"}],"text":"abold"}"#
);
let rt = Content::from_canonical_json(json).expect("storage lane accepts");
assert_eq!(rt.validate(), Ok(()), "loaded content invalid");
assert_eq!(rt.text, format!("a\n{slot}\nbold"));
assert_eq!(rt.lines.len(), 3);
assert_eq!(rt.lines[1].kind, LineKind::Island);
assert_eq!(rt.marks, vec![Mark::new(4, 8, MarkKind::Strong)]);
let md = crate::export::to_markdown(&rt);
let back = crate::import::from_markdown(&md).expect("re-imports");
assert_eq!(back, rt, "{md:?}");
}
#[test]
fn built_in_decoders_read_the_legacy_sibling_form() {
let cases: [(Value, LineKind); 2] = [
(
serde_json::json!({"kind": "heading", "level": 2}),
LineKind::Heading { level: 2 },
),
(
serde_json::json!({"kind": "code", "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", "ordered": true, "start": 3, "ordinal": 1
});
assert_eq!(
container_from_value(&item).unwrap(),
Container::ListItem {
ordered: true,
start: 3,
ordinal: 1,
instance: 0,
}
);
for (v, want) in [
(
serde_json::json!({"start": 0, "end": 1, "type": "link", "url": "u"}),
MarkKind::Link { url: "u".into() },
),
(
serde_json::json!({"start": 0, "end": 1, "type": "anchor", "id": "a1"}),
MarkKind::Anchor { id: "a1".into() },
),
] {
assert_eq!(mark_from_value(&v).unwrap().kind, want);
}
let both = serde_json::json!({"kind": "heading", "level": 3, "attrs": {"level": 2}});
assert_eq!(
line_kind_from_value(&both).unwrap(),
LineKind::Heading { level: 2 }
);
let legacy = r#"{"islands":[],"lines":[{"containers":[],"kind":"heading","level":2}],"marks":[],"text":"hi"}"#;
assert_eq!(
Content::from_canonical_json(legacy)
.unwrap()
.to_canonical_json(),
r#"{"islands":[],"lines":[{"attrs":{"level":2},"containers":[],"kind":"heading"}],"marks":[],"text":"hi"}"#
);
}
#[test]
fn an_empty_bag_does_not_shadow_the_legacy_sibling() {
assert_eq!(
line_kind_from_value(&serde_json::json!({
"kind": "heading", "level": 2, "attrs": {}
}))
.unwrap(),
LineKind::Heading { level: 2 }
);
assert_eq!(
container_from_value(&serde_json::json!({
"container": "list_item", "attrs": {}, "ordered": true, "start": 3, "ordinal": 1
}))
.unwrap(),
Container::ListItem {
ordered: true,
start: 3,
ordinal: 1,
instance: 0,
}
);
}
#[test]
fn the_mark_tie_break_is_what_the_wire_carries() {
let all = [
MarkKind::Strong,
MarkKind::Emph,
MarkKind::Underline,
MarkKind::Strike,
MarkKind::Code,
MarkKind::Link { url: "u".into() },
MarkKind::Anchor { id: "a".into() },
];
for k in &all {
match k {
MarkKind::Strong
| MarkKind::Emph
| MarkKind::Underline
| MarkKind::Strike
| MarkKind::Code
| MarkKind::Link { .. }
| MarkKind::Anchor { .. } => {}
}
}
for k in &all {
let wire = mark_to_value(&Mark::new(0, 1, k.clone()));
let attrs = match wire.get("attrs") {
Some(a) => crate::model::canonical_json_string(a),
None => String::new(),
};
assert_eq!(
k.sort_key(),
(wire["type"].as_str().unwrap().to_string(), attrs),
"{k:?}"
);
}
}
}