use crate::normalize::is_bidi_char;
use serde_json::Value as JsonValue;
use std::borrow::Cow;
pub type Usv = usize;
pub const ISLAND_SLOT: char = '\u{FFFC}';
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Content {
pub text: String,
pub lines: Vec<Line>,
pub marks: Vec<Mark>,
pub islands: Vec<Island>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Line {
pub kind: LineKind,
pub containers: Vec<Container>,
pub continues: bool,
}
impl Line {
pub fn new(kind: LineKind) -> Self {
Line {
kind,
containers: Vec::new(),
continues: false,
}
}
pub fn with_containers(mut self, containers: Vec<Container>) -> Self {
self.containers = containers;
self
}
pub fn with_continues(mut self, continues: bool) -> Self {
self.continues = continues;
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum LineKind {
Para,
Heading {
level: u8,
},
Code {
lang: Option<String>,
},
Island,
Rule,
Unknown {
tag: String,
attrs: JsonValue,
},
}
impl LineKind {
pub fn projects_as_para(&self) -> bool {
matches!(self, LineKind::Para | LineKind::Unknown { .. })
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Container {
ListItem {
ordered: bool,
start: u64,
ordinal: u64,
},
Quote,
Unknown {
tag: String,
attrs: JsonValue,
},
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Mark {
pub start: Usv,
pub end: Usv,
pub kind: MarkKind,
}
impl Mark {
pub fn new(start: Usv, end: Usv, kind: MarkKind) -> Self {
Mark { start, end, kind }
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum MarkKind {
Strong,
Emph,
Underline,
Strike,
Code,
Link {
url: String,
},
Anchor {
id: String,
},
Unknown {
tag: String,
attrs: JsonValue,
},
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Island {
pub id: String,
pub island_type: String,
pub props: JsonValue,
pub loss: Loss,
}
impl Island {
pub fn new(id: String, island_type: String) -> Self {
Island {
id,
island_type,
props: JsonValue::Null,
loss: Loss::LOSSLESS,
}
}
pub fn with_props(mut self, props: JsonValue) -> Self {
self.props = props;
self
}
pub fn with_loss(mut self, loss: Loss) -> Self {
self.loss = loss;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Loss(Cow<'static, str>);
impl Loss {
pub const LOSSLESS: Loss = Loss(Cow::Borrowed(Fidelity::Lossless.as_str()));
pub const DEGRADED: Loss = Loss(Cow::Borrowed(Fidelity::Degraded.as_str()));
pub const UNREPRESENTABLE: Loss = Loss(Cow::Borrowed(Fidelity::Unrepresentable.as_str()));
pub fn new(class: &str) -> Loss {
match Fidelity::parse(class) {
Some(f) => Loss(Cow::Borrowed(f.as_str())),
None => Loss(Cow::Owned(class.to_string())),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn fidelity(&self) -> Fidelity {
Fidelity::parse(self.as_str()).unwrap_or(Fidelity::Unrepresentable)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fidelity {
Lossless,
Degraded,
Unrepresentable,
}
impl Fidelity {
pub const ALL: &'static [Fidelity] = &[
Fidelity::Lossless,
Fidelity::Degraded,
Fidelity::Unrepresentable,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Lossless => "lossless",
Self::Degraded => "degraded",
Self::Unrepresentable => "unrepresentable",
}
}
pub fn parse(class: &str) -> Option<Fidelity> {
Self::ALL.iter().copied().find(|f| f.as_str() == class)
}
}
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(&sort_keys_owned(v.clone())).unwrap_or_default()
}
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 json_depth_exceeds(v: &JsonValue, max: usize) -> bool {
let mut stack: Vec<(&JsonValue, usize)> = vec![(v, 0)];
while let Some((v, depth)) = stack.pop() {
match v {
JsonValue::Array(items) => {
if depth + 1 > max {
return true;
}
stack.extend(items.iter().map(|c| (c, depth + 1)));
}
JsonValue::Object(map) => {
if depth + 1 > max {
return true;
}
stack.extend(map.values().map(|c| (c, depth + 1)));
}
_ => {}
}
}
false
}
pub(crate) fn check_json_depth(v: &JsonValue, what: &'static str) -> Result<(), Invariant> {
if json_depth_exceeds(v, crate::MAX_JSON_DEPTH) {
return Err(Invariant::JsonTooDeep {
what,
max: crate::MAX_JSON_DEPTH,
});
}
Ok(())
}
pub(crate) fn canonicalize_keys(v: &mut JsonValue) {
if !is_value_key_sorted(v) {
*v = sort_keys_owned(std::mem::take(v));
}
}
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)]
#[non_exhaustive]
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),
ReservedUnknownLineKind(String),
ReservedUnknownContainer(String),
MarkEdgeOnNewline { at: Usv },
TableAlignsMismatch { aligns: usize, cols: usize },
TableRaggedRow { row: usize, width: usize, cols: usize },
TableCellNewline { cell: usize },
IslandIdCollision { id: String },
AnchorIdCollision { id: String },
TableHeaderNotArray,
LineKindMismatch { line: usize, mismatch: LineKindMismatch },
NestingTooDeep {
line: usize,
depth: usize,
max: usize,
},
JsonTooDeep { what: &'static str, max: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LineKindMismatch {
IslandNotOneSlot,
RuleNotEmpty,
CodeHasSlot,
}
pub fn line_kind_mismatch(kind: &LineKind, seg: &str) -> Option<LineKindMismatch> {
match kind {
LineKind::Island => {
let mut chars = seg.chars();
match (chars.next(), chars.next()) {
(Some(ISLAND_SLOT), None) => None,
_ => Some(LineKindMismatch::IslandNotOneSlot),
}
}
LineKind::Rule if !seg.is_empty() => Some(LineKindMismatch::RuleNotEmpty),
LineKind::Code { .. } if seg.contains(ISLAND_SLOT) => Some(LineKindMismatch::CodeHasSlot),
_ => None,
}
}
impl Content {
pub fn new(text: String, lines: Vec<Line>) -> Self {
Content {
text,
lines,
marks: Vec::new(),
islands: Vec::new(),
}
}
pub fn with_marks(mut self, marks: Vec<Mark>) -> Self {
self.marks = marks;
self
}
pub fn with_islands(mut self, islands: Vec<Island>) -> Self {
self.islands = islands;
self
}
pub fn empty() -> Self {
Content::new(String::new(), vec![Line::new(LineKind::Para)])
}
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 (line, seg) in self.lines.iter_mut().zip(self.text.split('\n')) {
if line_kind_mismatch(&line.kind, seg).is_some() {
line.kind = LineKind::Para;
}
if let LineKind::Unknown { attrs, .. } = &mut line.kind {
canonicalize_keys(attrs);
}
for c in &mut line.containers {
if let Container::Unknown { attrs, .. } = c {
canonicalize_keys(attrs);
}
}
}
for island in &mut self.islands {
crate::island::normalize_island_structure(island);
canonicalize_keys(&mut island.props);
}
for mark in &mut self.marks {
if let MarkKind::Unknown { attrs, .. } = &mut mark.kind {
canonicalize_keys(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 [&'static str] = &[
"strong",
"emph",
"underline",
"strike",
"code",
"link",
"anchor",
];
pub const RESERVED_LINE_KINDS: &'static [&'static str] =
&["para", "heading", "code", "island", "rule"];
pub const RESERVED_CONTAINERS: &'static [&'static str] = &["list_item", "quote"];
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();
let mut seen_anchor_ids = std::collections::HashSet::new();
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 });
}
}
match &m.kind {
MarkKind::Unknown { tag, attrs } => {
if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
return Err(Invariant::ReservedUnknownTag(tag.clone()));
}
check_json_depth(attrs, "mark attrs")?;
}
MarkKind::Anchor { id } => {
if id.is_empty() || !seen_anchor_ids.insert(id.as_str()) {
return Err(Invariant::AnchorIdCollision { id: id.clone() });
}
}
_ => {}
}
}
for (i, (line, seg)) in self.lines.iter().zip(self.text.split('\n')).enumerate() {
match &line.kind {
LineKind::Heading { level } if !(1..=6).contains(level) => {
return Err(Invariant::BadHeadingLevel(*level));
}
LineKind::Unknown { tag, attrs } => {
if Self::RESERVED_LINE_KINDS.contains(&tag.as_str()) {
return Err(Invariant::ReservedUnknownLineKind(tag.clone()));
}
check_json_depth(attrs, "line attrs")?;
}
_ => {}
}
for c in &line.containers {
if let Container::Unknown { tag, attrs } = c {
if Self::RESERVED_CONTAINERS.contains(&tag.as_str()) {
return Err(Invariant::ReservedUnknownContainer(tag.clone()));
}
check_json_depth(attrs, "container attrs")?;
}
}
if let Some(mismatch) = line_kind_mismatch(&line.kind, seg) {
return Err(Invariant::LineKindMismatch { line: i, mismatch });
}
if line.containers.len() > crate::MAX_NESTING_DEPTH {
return Err(Invariant::NestingTooDeep {
line: i,
depth: line.containers.len(),
max: crate::MAX_NESTING_DEPTH,
});
}
}
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(),
});
}
check_json_depth(&island.props, "island props")?;
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());
}
fn tagged(text: &str, kind: LineKind) -> Content {
Content {
text: text.to_string(),
lines: vec![Line {
kind,
containers: Vec::new(),
continues: false,
}],
marks: Vec::new(),
islands: Vec::new(),
}
}
#[test]
fn line_kind_must_agree_with_line_text() {
assert_eq!(
tagged("hello world", LineKind::Island).validate(),
Err(Invariant::LineKindMismatch {
line: 0,
mismatch: LineKindMismatch::IslandNotOneSlot
})
);
assert_eq!(
tagged("", LineKind::Island).validate(),
Err(Invariant::LineKindMismatch {
line: 0,
mismatch: LineKindMismatch::IslandNotOneSlot
})
);
assert_eq!(
tagged("important text", LineKind::Rule).validate(),
Err(Invariant::LineKindMismatch {
line: 0,
mismatch: LineKindMismatch::RuleNotEmpty
})
);
let mut code = tagged(&format!("a{ISLAND_SLOT}b"), LineKind::Code { lang: None });
code.islands = vec![Island {
id: "isl-0".into(),
island_type: "image".into(),
props: serde_json::json!({"alt": "x", "url": "y.png"}),
loss: Loss::LOSSLESS,
}];
assert_eq!(
code.validate(),
Err(Invariant::LineKindMismatch {
line: 0,
mismatch: LineKindMismatch::CodeHasSlot
})
);
let mut para = code.clone();
para.lines[0].kind = LineKind::Para;
assert_eq!(para.validate(), Ok(()));
let mut heading = code.clone();
heading.lines[0].kind = LineKind::Heading { level: 1 };
assert_eq!(heading.validate(), Ok(()));
assert_eq!(tagged("", LineKind::Rule).validate(), Ok(()));
}
#[test]
fn normalize_demotes_a_stranded_line_kind() {
let mut rt = tagged("typed into a table line", LineKind::Island);
rt.normalize();
assert_eq!(rt.lines[0].kind, LineKind::Para);
assert_eq!(rt.validate(), Ok(()));
let mut rt = tagged("text on a rule line", LineKind::Rule);
rt.normalize();
assert_eq!(rt.lines[0].kind, LineKind::Para);
let mut rt = tagged(&ISLAND_SLOT.to_string(), LineKind::Island);
rt.islands = vec![Island {
id: "isl-0".into(),
island_type: "image".into(),
props: serde_json::json!({"alt": "x", "url": "y.png"}),
loss: Loss::LOSSLESS,
}];
rt.normalize();
assert_eq!(rt.lines[0].kind, LineKind::Island);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn container_nesting_is_capped() {
let mut rt = tagged("hi", LineKind::Para);
rt.lines[0].containers = vec![Container::Quote; crate::MAX_NESTING_DEPTH];
assert_eq!(rt.validate(), Ok(()));
rt.lines[0].containers.push(Container::Quote);
assert_eq!(
rt.validate(),
Err(Invariant::NestingTooDeep {
line: 0,
depth: crate::MAX_NESTING_DEPTH + 1,
max: crate::MAX_NESTING_DEPTH,
})
);
}
#[test]
fn json_payload_depth_is_capped() {
let nested = |depth: usize| {
let mut v = JsonValue::Null;
for _ in 0..depth {
v = JsonValue::Array(vec![v]);
}
v
};
let too_deep = |what: &'static str| {
Err(Invariant::JsonTooDeep {
what,
max: crate::MAX_JSON_DEPTH,
})
};
let mut rt = tagged("hi", LineKind::Para);
rt.lines[0].kind = LineKind::Unknown {
tag: "callout".into(),
attrs: nested(crate::MAX_JSON_DEPTH),
};
assert_eq!(rt.validate(), Ok(()));
rt.lines[0].kind = LineKind::Unknown {
tag: "callout".into(),
attrs: nested(crate::MAX_JSON_DEPTH + 1),
};
assert_eq!(rt.validate(), too_deep("line attrs"));
let mut rt = tagged("hi", LineKind::Para);
rt.lines[0].containers = vec![Container::Unknown {
tag: "indent".into(),
attrs: nested(crate::MAX_JSON_DEPTH + 1),
}];
assert_eq!(rt.validate(), too_deep("container attrs"));
let mut rt = tagged("hi", LineKind::Para);
rt.marks = vec![Mark {
start: 0,
end: 2,
kind: MarkKind::Unknown {
tag: "sparkle".into(),
attrs: nested(crate::MAX_JSON_DEPTH + 1),
},
}];
assert_eq!(rt.validate(), too_deep("mark attrs"));
let mut rt = tagged("\u{fffc}", LineKind::Island);
rt.islands = vec![Island {
id: "i1".into(),
island_type: "widget".into(),
props: nested(crate::MAX_JSON_DEPTH + 1),
loss: Loss::LOSSLESS,
}];
assert_eq!(rt.validate(), too_deep("island props"));
}
#[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 unrecognized_cell_key_survives_normalize() {
let mut rt = table_rt(serde_json::json!({
"aligns": ["none", "none"],
"header": [{"text": "h", "marks": [], "colspan": 2}, cell("h2")],
"rows": [[cell("a")]],
}));
rt.normalize();
assert_eq!(rt.islands[0].props["header"][0]["colspan"], 2);
assert!(rt.islands[0].props["rows"][0][1].get("colspan").is_none());
assert!(rt.to_canonical_json().contains(r#""colspan":2"#));
}
#[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 duplicate_or_empty_anchor_id_is_rejected() {
let mut rt = Content::empty();
rt.text = "abcd".into();
let anchor = |start, end, id: &str| Mark {
start,
end,
kind: MarkKind::Anchor { id: id.into() },
};
rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "x")];
assert_eq!(
rt.validate(),
Err(Invariant::AnchorIdCollision { id: "x".into() })
);
rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "y")];
assert_eq!(rt.validate(), Ok(()));
rt.marks = vec![anchor(0, 2, "")];
assert_eq!(
rt.validate(),
Err(Invariant::AnchorIdCollision { id: String::new() })
);
}
#[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);
}
}