use crate::normalize::is_bidi_char;
use serde_json::Value as JsonValue;
pub type Usv = usize;
pub const ISLAND_SLOT: char = '\u{FFFC}';
#[derive(Debug, Clone, PartialEq)]
pub struct Content {
pub text: String,
pub lines: Vec<Line>,
pub marks: Vec<Mark>,
pub islands: Vec<Island>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
pub kind: LineKind,
pub containers: Vec<Container>,
pub continues: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineKind {
Para,
Heading {
level: u8,
},
Code {
lang: Option<String>,
},
Island,
Rule,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Container {
ListItem {
ordered: bool,
start: u64,
ordinal: u64,
},
Quote,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Mark {
pub start: Usv,
pub end: Usv,
pub kind: MarkKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarkKind {
Strong,
Emph,
Underline,
Strike,
Code,
Link {
url: String,
},
Anchor {
id: String,
},
Unknown {
tag: String,
attrs: JsonValue,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Island {
pub id: String,
pub island_type: String,
pub props: JsonValue,
pub loss: Loss,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Loss {
Lossless,
Degraded,
Unrepresentable,
}
impl MarkKind {
pub fn is_formatting(&self) -> bool {
matches!(
self,
MarkKind::Strong
| MarkKind::Emph
| MarkKind::Underline
| MarkKind::Strike
| MarkKind::Code
| MarkKind::Link { .. }
)
}
pub fn ord(&self) -> u8 {
match self {
MarkKind::Strong => 0,
MarkKind::Emph => 1,
MarkKind::Underline => 2,
MarkKind::Strike => 3,
MarkKind::Code => 4,
MarkKind::Link { .. } => 5,
MarkKind::Anchor { .. } => 6,
MarkKind::Unknown { .. } => 7,
}
}
pub fn attrs_key(&self) -> String {
match self {
MarkKind::Link { url } => url.clone(),
MarkKind::Anchor { id } => id.clone(),
MarkKind::Unknown { tag, attrs } => {
format!("{}\u{0}{}", tag, canonical_json_string(attrs))
}
_ => String::new(),
}
}
}
fn canonical_json_string(v: &JsonValue) -> String {
serde_json::to_string(&sorted_value(v)).unwrap_or_default()
}
pub(crate) fn sorted_value(v: &JsonValue) -> JsonValue {
match v {
JsonValue::Array(items) => JsonValue::Array(items.iter().map(sorted_value).collect()),
JsonValue::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = serde_json::Map::with_capacity(map.len());
for k in keys {
out.insert(k.clone(), sorted_value(&map[k]));
}
JsonValue::Object(out)
}
other => other.clone(),
}
}
pub(crate) fn is_value_key_sorted(v: &JsonValue) -> bool {
match v {
JsonValue::Array(items) => items.iter().all(is_value_key_sorted),
JsonValue::Object(map) => {
map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
&& map.values().all(is_value_key_sorted)
}
_ => true,
}
}
pub(crate) fn sort_keys_owned(v: JsonValue) -> JsonValue {
match v {
JsonValue::Array(items) => {
JsonValue::Array(items.into_iter().map(sort_keys_owned).collect())
}
JsonValue::Object(map) => {
let mut entries: Vec<(String, JsonValue)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = serde_json::Map::with_capacity(entries.len());
for (k, child) in entries {
out.insert(k, sort_keys_owned(child));
}
JsonValue::Object(out)
}
other => other,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Invariant {
CarriageReturn,
BidiControl(char),
IslandSlotMismatch { slots: usize, islands: usize },
LineCountMismatch { lines: usize, segments: usize },
MarkOutOfRange { start: Usv, end: Usv, len: Usv },
ZeroWidthFormatting { at: Usv },
BadHeadingLevel(u8),
FirstLineContinues,
ReservedUnknownTag(String),
MarkEdgeOnNewline { at: Usv },
TableAlignsMismatch { aligns: usize, cols: usize },
TableRaggedRow { row: usize, width: usize, cols: usize },
TableCellNewline { cell: usize },
IslandIdCollision { id: String },
TableHeaderNotArray,
}
impl Content {
pub fn empty() -> Self {
Content {
text: String::new(),
lines: vec![Line {
kind: LineKind::Para,
containers: Vec::new(),
continues: false,
}],
marks: Vec::new(),
islands: Vec::new(),
}
}
pub fn len_usv(&self) -> Usv {
self.text.chars().count()
}
pub fn is_inline(&self) -> bool {
self.islands.is_empty()
&& self.lines.len() == 1
&& self.lines[0].kind == LineKind::Para
&& self.lines[0].containers.is_empty()
}
pub fn is_plain(&self) -> bool {
self.marks.is_empty()
&& self.islands.is_empty()
&& self
.lines
.iter()
.all(|l| l.kind == LineKind::Para && l.containers.is_empty())
}
pub fn is_blank(&self) -> bool {
self.text.trim().is_empty()
}
pub fn segment_count(&self) -> usize {
self.text.chars().filter(|c| *c == '\n').count() + 1
}
pub fn normalize(&mut self) {
for island in &mut self.islands {
crate::island::normalize_island_structure(island);
if !is_value_key_sorted(&island.props) {
island.props = sorted_value(&island.props);
}
}
for mark in &mut self.marks {
if let MarkKind::Unknown { attrs, .. } = &mut mark.kind {
if !is_value_key_sorted(attrs) {
*attrs = sorted_value(attrs);
}
}
}
if self.marks.iter().any(|m| m.kind.is_formatting()) {
let chars: Vec<char> = self.text.chars().collect();
for m in &mut self.marks {
if m.kind.is_formatting() {
while m.start < m.end && chars.get(m.start) == Some(&'\n') {
m.start += 1;
}
while m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
m.end -= 1;
}
}
}
}
self.marks = normalize_marks(std::mem::take(&mut self.marks));
}
pub const RESERVED_MARK_TYPES: [&'static str; 7] = [
"strong",
"emph",
"underline",
"strike",
"code",
"link",
"anchor",
];
pub fn validate(&self) -> Result<(), Invariant> {
let mut slots = 0usize;
for c in self.text.chars() {
if c == '\r' {
return Err(Invariant::CarriageReturn);
}
if is_bidi_char(c) {
return Err(Invariant::BidiControl(c));
}
if c == ISLAND_SLOT {
slots += 1;
}
}
if slots != self.islands.len() {
return Err(Invariant::IslandSlotMismatch {
slots,
islands: self.islands.len(),
});
}
let segments = self.segment_count();
if self.lines.len() != segments {
return Err(Invariant::LineCountMismatch {
lines: self.lines.len(),
segments,
});
}
if self.lines.first().is_some_and(|l| l.continues) {
return Err(Invariant::FirstLineContinues);
}
let len = self.len_usv();
let chars: Vec<char> = self.text.chars().collect();
for m in &self.marks {
if m.start > m.end || m.end > len {
return Err(Invariant::MarkOutOfRange {
start: m.start,
end: m.end,
len,
});
}
if m.start == m.end && m.kind.is_formatting() {
return Err(Invariant::ZeroWidthFormatting { at: m.start });
}
if m.kind.is_formatting() {
if chars.get(m.start) == Some(&'\n') {
return Err(Invariant::MarkEdgeOnNewline { at: m.start });
}
if m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
return Err(Invariant::MarkEdgeOnNewline { at: m.end - 1 });
}
}
if let MarkKind::Unknown { tag, .. } = &m.kind {
if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
return Err(Invariant::ReservedUnknownTag(tag.clone()));
}
}
}
for line in &self.lines {
if let LineKind::Heading { level } = line.kind {
if !(1..=6).contains(&level) {
return Err(Invariant::BadHeadingLevel(level));
}
}
}
let mut seen_ids = std::collections::HashSet::with_capacity(self.islands.len());
for island in &self.islands {
if !seen_ids.insert(island.id.as_str()) {
return Err(Invariant::IslandIdCollision {
id: island.id.clone(),
});
}
if let Some(e) = crate::island::island_shape_error(island) {
return Err(e);
}
for (text, marks) in crate::island::island_cell_marks(island) {
let clen = text.chars().count();
for m in &marks {
if m.start > m.end || m.end > clen {
return Err(Invariant::MarkOutOfRange {
start: m.start,
end: m.end,
len: clen,
});
}
if m.start == m.end && m.kind.is_formatting() {
return Err(Invariant::ZeroWidthFormatting { at: m.start });
}
if let MarkKind::Unknown { tag, .. } = &m.kind {
if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
return Err(Invariant::ReservedUnknownTag(tag.clone()));
}
}
}
}
}
Ok(())
}
}
pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(u8, String), Vec<(Usv, Usv)>> = BTreeMap::new();
let mut kind_of: BTreeMap<(u8, String), MarkKind> = BTreeMap::new();
let mut passthrough: Vec<Mark> = Vec::new();
for m in marks {
if m.kind.is_formatting() {
if m.start >= m.end {
continue; }
let key = (m.kind.ord(), m.kind.attrs_key());
kind_of.entry(key.clone()).or_insert_with(|| m.kind.clone());
groups.entry(key).or_default().push((m.start, m.end));
} else {
passthrough.push(m);
}
}
let mut out: Vec<Mark> = Vec::new();
for (key, mut ranges) in groups {
ranges.sort_unstable();
let kind = kind_of.remove(&key).expect("kind recorded with group");
let mut cur = ranges[0];
for &(s, e) in &ranges[1..] {
if s <= cur.1 {
cur.1 = cur.1.max(e);
} else {
out.push(Mark {
start: cur.0,
end: cur.1,
kind: kind.clone(),
});
cur = (s, e);
}
}
out.push(Mark {
start: cur.0,
end: cur.1,
kind,
});
}
out.extend(passthrough);
out.sort_by_cached_key(|m| (m.start, m.end, m.kind.ord(), m.kind.attrs_key()));
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
fn f(start: Usv, end: Usv, kind: MarkKind) -> Mark {
Mark { start, end, kind }
}
#[test]
fn is_blank_tracks_whitespace_and_islands() {
assert!(Content::empty().is_blank());
let mut ws = Content::empty();
ws.text = " \n\t ".to_string();
ws.lines = vec![
Line {
kind: LineKind::Para,
containers: Vec::new(),
continues: false,
},
Line {
kind: LineKind::Para,
containers: Vec::new(),
continues: false,
},
];
assert!(ws.is_blank(), "whitespace-only text is blank");
let mut has_text = Content::empty();
has_text.text = "x".to_string();
assert!(!has_text.is_blank());
let mut island_only = Content::empty();
island_only.text = ISLAND_SLOT.to_string();
assert!(!island_only.is_blank());
}
#[test]
fn same_kind_adjacent_unions() {
let got = normalize_marks(vec![f(3, 6, MarkKind::Strong), f(0, 3, MarkKind::Strong)]);
assert_eq!(got, vec![f(0, 6, MarkKind::Strong)]);
}
#[test]
fn same_kind_overlapping_unions() {
let got = normalize_marks(vec![f(0, 4, MarkKind::Emph), f(2, 7, MarkKind::Emph)]);
assert_eq!(got, vec![f(0, 7, MarkKind::Emph)]);
}
#[test]
fn different_kinds_overlap_freely() {
let got = normalize_marks(vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]);
assert_eq!(
got,
vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]
);
}
#[test]
fn links_union_only_at_same_url() {
let a = MarkKind::Link { url: "a".into() };
let b = MarkKind::Link { url: "b".into() };
let got = normalize_marks(vec![
f(0, 2, a.clone()),
f(2, 4, a.clone()),
f(4, 6, b.clone()),
]);
assert_eq!(got, vec![f(0, 4, a), f(4, 6, b)]);
}
#[test]
fn identity_never_merges() {
let a = MarkKind::Anchor { id: "c1".into() };
let b = MarkKind::Anchor { id: "c2".into() };
let got = normalize_marks(vec![f(3, 3, a.clone()), f(3, 3, b.clone())]);
assert_eq!(got.len(), 2);
assert!(got.contains(&f(3, 3, a)));
assert!(got.contains(&f(3, 3, b)));
}
#[test]
fn zero_width_formatting_dropped_zero_width_anchor_kept() {
let got = normalize_marks(vec![
f(2, 2, MarkKind::Strong),
f(2, 2, MarkKind::Anchor { id: "x".into() }),
]);
assert_eq!(got, vec![f(2, 2, MarkKind::Anchor { id: "x".into() })]);
}
#[test]
fn empty_is_valid() {
assert_eq!(Content::empty().validate(), Ok(()));
}
#[test]
fn is_inline_accepts_empty_and_single_para() {
assert!(Content::empty().is_inline());
assert!(crate::import::from_markdown("just one line")
.unwrap()
.is_inline());
assert!(crate::import::from_markdown("a *bold* run")
.unwrap()
.is_inline());
}
#[test]
fn is_inline_rejects_blocks_containers_and_islands() {
assert!(!crate::import::from_markdown("one\n\ntwo")
.unwrap()
.is_inline());
assert!(!crate::import::from_markdown("# heading")
.unwrap()
.is_inline());
assert!(!crate::import::from_markdown("- item").unwrap().is_inline());
}
#[test]
fn validate_catches_slot_mismatch() {
let mut rt = Content::empty();
rt.text = "\u{FFFC}".into();
rt.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
assert_eq!(
rt.validate(),
Err(Invariant::IslandSlotMismatch {
slots: 1,
islands: 0
})
);
}
#[test]
fn validate_catches_line_count() {
let mut rt = Content::empty();
rt.text = "a\nb".into(); assert_eq!(
rt.validate(),
Err(Invariant::LineCountMismatch {
lines: 1,
segments: 2
})
);
}
#[test]
fn normalize_is_idempotent() {
let mut rt = Content::empty();
rt.text = "hello world".into();
rt.marks = vec![
f(6, 11, MarkKind::Strong),
f(0, 5, MarkKind::Strong),
f(0, 5, MarkKind::Emph),
];
rt.normalize();
let once = rt.marks.clone();
rt.normalize();
assert_eq!(rt.marks, once);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn table_cell_marks_normalize_and_are_idempotent() {
fn table(cell_marks: serde_json::Value) -> Content {
let mut rt = Content::empty();
rt.text = ISLAND_SLOT.to_string();
rt.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
rt.islands = vec![Island {
id: "i".into(),
island_type: "table".into(),
props: serde_json::json!({
"aligns": ["none"],
"header": [{"text": "abcd", "marks": cell_marks}],
"rows": [],
}),
loss: Loss::Lossless,
}];
rt
}
let mut a = table(serde_json::json!([
{"start": 2, "end": 4, "type": "strong"},
{"start": 1, "end": 1, "type": "strong"},
{"start": 0, "end": 2, "type": "strong"}
]));
a.normalize();
assert_eq!(a.validate(), Ok(()));
let cell = &a.islands[0].props["header"][0];
assert_eq!(cell["marks"].as_array().unwrap().len(), 1);
assert_eq!(cell["marks"][0]["start"], 0);
assert_eq!(cell["marks"][0]["end"], 4);
let mut b = table(serde_json::json!([
{"start": 0, "end": 2, "type": "strong"},
{"start": 2, "end": 4, "type": "strong"}
]));
b.normalize();
assert_eq!(a.to_canonical_json(), b.to_canonical_json());
let once = a.to_canonical_json();
a.normalize();
assert_eq!(a.to_canonical_json(), once);
}
#[test]
fn validate_catches_cell_mark_out_of_range() {
let mut rt = Content::empty();
rt.text = ISLAND_SLOT.to_string();
rt.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
rt.islands = vec![Island {
id: "i".into(),
island_type: "table".into(),
props: serde_json::json!({
"aligns": ["none"],
"header": [{"text": "ab", "marks": [{"start": 0, "end": 5, "type": "strong"}]}],
"rows": [],
}),
loss: Loss::Lossless,
}];
assert_eq!(
rt.validate(),
Err(Invariant::MarkOutOfRange {
start: 0,
end: 5,
len: 2
})
);
}
fn table_rt(props: serde_json::Value) -> Content {
let mut rt = Content::empty();
rt.text = ISLAND_SLOT.to_string();
rt.lines = vec![Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
}];
rt.islands = vec![Island {
id: "i".into(),
island_type: "table".into(),
props,
loss: Loss::Lossless,
}];
rt
}
fn cell(t: &str) -> serde_json::Value {
serde_json::json!({ "text": t, "marks": [] })
}
#[test]
fn validate_catches_table_shape() {
let rt = table_rt(serde_json::json!({
"aligns": ["none", "none"],
"header": [cell("a"), cell("b")],
"rows": [[cell("1"), cell("2"), cell("3")]],
}));
assert_eq!(
rt.validate(),
Err(Invariant::TableRaggedRow {
row: 0,
width: 3,
cols: 2
})
);
let rt = table_rt(serde_json::json!({
"aligns": ["none"],
"header": [cell("a"), cell("b")],
"rows": [],
}));
assert_eq!(
rt.validate(),
Err(Invariant::TableAlignsMismatch { aligns: 1, cols: 2 })
);
let rt = table_rt(serde_json::json!({
"aligns": ["none", "none"],
"header": [cell("a"), cell("b\nc")],
"rows": [],
}));
assert_eq!(rt.validate(), Err(Invariant::TableCellNewline { cell: 1 }));
}
#[test]
fn normalize_repairs_table_shape() {
let mut rt = table_rt(serde_json::json!({
"aligns": ["none"],
"header": [cell("h")],
"rows": [
[cell("a"), cell("b"), cell("c")],
[cell("d\ne")],
],
}));
rt.normalize();
assert_eq!(rt.validate(), Ok(()));
let props = &rt.islands[0].props;
assert_eq!(props["header"].as_array().unwrap().len(), 3);
assert_eq!(props["aligns"].as_array().unwrap().len(), 3);
for row in props["rows"].as_array().unwrap() {
assert_eq!(row.as_array().unwrap().len(), 3);
}
assert_eq!(props["aligns"][2], serde_json::json!("none"));
assert_eq!(props["header"][1]["text"], serde_json::json!(""));
assert_eq!(props["rows"][1][0]["text"], serde_json::json!("d e"));
let once = rt.to_canonical_json();
rt.normalize();
assert_eq!(rt.to_canonical_json(), once);
}
#[test]
fn empty_table_is_valid() {
let mut rt = table_rt(serde_json::json!({
"aligns": [],
"header": [],
"rows": [],
}));
assert_eq!(rt.validate(), Ok(()));
rt.normalize();
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn non_array_table_header_is_rejected_then_repaired() {
let mut rt = table_rt(serde_json::json!({
"header": "oops",
"aligns": [],
"rows": [],
}));
assert_eq!(rt.validate(), Err(Invariant::TableHeaderNotArray));
rt.normalize();
assert_eq!(rt.validate(), Ok(()));
assert_eq!(rt.islands[0].props["header"], serde_json::json!([]));
}
#[test]
fn duplicate_island_id_is_rejected() {
let mut rt = Content::empty();
rt.text = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}");
rt.lines = vec![
Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
},
Line {
kind: LineKind::Island,
containers: vec![],
continues: false,
},
];
let table = |id: &str| Island {
id: id.into(),
island_type: "table".into(),
props: serde_json::json!({ "header": [cell("h")], "aligns": ["none"], "rows": [] }),
loss: Loss::Lossless,
};
rt.islands = vec![table("dup"), table("dup")];
assert_eq!(
rt.validate(),
Err(Invariant::IslandIdCollision { id: "dup".into() })
);
rt.islands = vec![table("a"), table("b")];
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn normalize_dedupes_identical_identity_marks() {
let mut rt = Content::empty();
rt.text = "abcd".into();
let anchor = |id: &str| Mark {
start: 0,
end: 4,
kind: MarkKind::Anchor { id: id.into() },
};
rt.marks = vec![anchor("x"), anchor("x")];
rt.normalize();
assert_eq!(rt.marks, vec![anchor("x")]);
rt.marks = vec![anchor("x"), anchor("y")];
rt.normalize();
assert_eq!(rt.marks.len(), 2);
}
}