use crate::model::{
sort_keys_owned, sorted_value, Container, Invariant, Island, Line, LineKind, Loss, Mark,
MarkKind, Content,
};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
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 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))
}
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());
}
}
Value::Object(m)
}
pub fn line_kind_from_value(v: &Value) -> Result<LineKind, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("line"))?;
match o.get("kind").and_then(Value::as_str) {
Some("para") => Ok(LineKind::Para),
Some("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 })
}
Some("code") => Ok(LineKind::Code {
lang: o.get("lang").and_then(Value::as_str).map(str::to_string),
}),
Some("island") => Ok(LineKind::Island),
Some("rule") => Ok(LineKind::Rule),
_ => Err(ParseError::Shape("line kind")),
}
}
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());
}
}
Value::Object(m)
}
pub fn container_from_value(v: &Value) -> Result<Container, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("container"))?;
match o.get("container").and_then(Value::as_str) {
Some("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),
}),
Some("quote") => Ok(Container::Quote),
_ => Err(ParseError::Shape("container kind")),
}
}
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(), sorted_value(attrs));
}
}
Value::Object(m)
}
pub fn mark_from_value(v: &Value) -> Result<Mark, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("mark"))?;
let start = o
.get("start")
.and_then(Value::as_u64)
.ok_or(ParseError::Shape("mark start"))? as usize;
let end = o
.get("end")
.and_then(Value::as_u64)
.ok_or(ParseError::Shape("mark end"))? as usize;
let ty = o
.get("type")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("mark type"))?;
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: o.get("attrs").cloned().unwrap_or(Value::Null),
},
};
Ok(Mark { start, end, kind })
}
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_cells(props: &Value) -> Vec<(String, Vec<Mark>)> {
let mut out = Vec::new();
if let Some(h) = props.get("header").and_then(Value::as_array) {
out.extend(h.iter().map(parse_cell));
}
if let Some(rows) = props.get("rows").and_then(Value::as_array) {
for row in rows {
if let Some(r) = row.as_array() {
out.extend(r.iter().map(parse_cell));
}
}
}
out
}
fn island_is_mark_carrying(island_type: &str) -> bool {
matches!(island_type, "table")
}
pub(crate) fn normalize_island_structure(island: &mut Island) {
if island_is_mark_carrying(&island.island_type) {
normalize_table_props(&mut island.props);
}
}
pub(crate) fn island_cell_marks(island: &Island) -> Vec<(String, Vec<Mark>)> {
if island_is_mark_carrying(&island.island_type) {
table_cells(&island.props)
} else {
Vec::new()
}
}
pub(crate) fn island_shape_error(island: &Island) -> Option<Invariant> {
if island_is_mark_carrying(&island.island_type) {
table_shape_error(&island.props)
} else {
None
}
}
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
};
*cell = cell_to_value(&text, &crate::model::normalize_marks(marks));
}
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(), sorted_value(&island.props));
m.insert("loss".into(), loss_to_str(island.loss).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: o.get("props").cloned().unwrap_or(Value::Null),
loss: loss_from_str(o.get("loss").and_then(Value::as_str).unwrap_or("lossless")),
})
}
fn loss_to_str(loss: Loss) -> &'static str {
match loss {
Loss::Lossless => "lossless",
Loss::Degraded => "degraded",
Loss::Unrepresentable => "unrepresentable",
}
}
fn loss_from_str(s: &str) -> Loss {
match s {
"lossless" => Loss::Lossless,
"degraded" => Loss::Degraded,
_ => Loss::Unrepresentable,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{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 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_defaults_unrepresentable() {
let json = r#"{"text":"","lines":[{"kind":"island","containers":[]}],"marks":[],"islands":[{"id":"i1","type":"widget","props":{},"loss":"future_class"}]}"#;
let rt = Content::from_canonical_json(json).unwrap();
assert_eq!(rt.islands[0].loss, Loss::Unrepresentable);
}
#[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);
}
}