use crate::island::IslandType;
use crate::normalize::{is_bidi_char, is_line_separator};
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)]
pub struct Content {
pub text: String,
pub lines: Vec<Line>,
pub marks: Vec<Mark>,
pub islands: Vec<Island>,
}
#[derive(Debug, Clone, PartialEq)]
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)]
pub enum LineKind {
Para,
Heading {
level: u8,
},
Code {
lang: Option<String>,
},
Island,
Rule,
}
impl LineKind {
pub fn takes_continuations(&self) -> bool {
matches!(self, LineKind::Para | LineKind::Code { .. })
}
pub fn tag(&self) -> &'static str {
match self {
LineKind::Para => "para",
LineKind::Heading { .. } => "heading",
LineKind::Code { .. } => "code",
LineKind::Island => "island",
LineKind::Rule => "rule",
}
}
pub fn attrs(&self) -> Cow<'_, JsonValue> {
match self {
LineKind::Para | LineKind::Island | LineKind::Rule => Cow::Owned(JsonValue::Null),
LineKind::Heading { level } => Cow::Owned(bag([("level", (*level).into())])),
LineKind::Code { lang } => Cow::Owned(match lang {
Some(l) => bag([("lang", l.as_str().into())]),
None => JsonValue::Null,
}),
}
}
}
fn bag<const N: usize>(entries: [(&str, JsonValue); N]) -> JsonValue {
debug_assert!(entries.windows(2).all(|w| w[0].0 < w[1].0));
let mut m = serde_json::Map::with_capacity(N);
for (k, v) in entries {
m.insert(k.to_string(), v);
}
JsonValue::Object(m)
}
pub(crate) fn is_empty_bag(v: &JsonValue) -> bool {
match v {
JsonValue::Null => true,
JsonValue::Object(m) => m.is_empty(),
_ => false,
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Container {
ListItem {
ordered: bool,
start: u64,
ordinal: u64,
instance: u64,
},
Quote { instance: u64 },
}
impl Container {
pub fn instance(&self) -> u64 {
match self {
Container::ListItem { instance, .. } | Container::Quote { instance } => *instance,
}
}
pub fn tag(&self) -> &'static str {
match self {
Container::ListItem { .. } => "list_item",
Container::Quote { .. } => "quote",
}
}
pub fn attrs(&self) -> Cow<'_, JsonValue> {
match self {
Container::ListItem {
ordered,
start,
ordinal,
..
} => Cow::Owned(bag([
("ordered", (*ordered).into()),
("ordinal", (*ordinal).into()),
("start", (*start).into()),
])),
Container::Quote { .. } => Cow::Owned(JsonValue::Null),
}
}
fn set_instance(&mut self, n: u64) {
match self {
Container::ListItem { instance, .. } | Container::Quote { instance } => *instance = n,
}
}
pub fn same_run(&self, other: &Container) -> bool {
match (self, other) {
(
Container::ListItem {
ordered: a, start: b, ..
},
Container::ListItem {
ordered: c, start: d, ..
},
) => a == c && b == d,
(Container::Quote { .. }, Container::Quote { .. }) => true,
_ => false,
}
}
pub fn same_weld(&self, other: &Container) -> bool {
match (self, other) {
(Container::ListItem { ordered: a, .. }, Container::ListItem { ordered: b, .. }) => {
a == b
}
_ => self.same_run(other),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Normalized(Content);
impl Normalized {
pub fn empty() -> Normalized {
Normalized(Content::empty())
}
pub fn into_content(self) -> Content {
self.0
}
pub(crate) fn as_content_mut(&mut self) -> &mut Content {
&mut self.0
}
}
impl From<Content> for Normalized {
fn from(rt: Content) -> Normalized {
rt.into_normalized()
}
}
impl std::ops::Deref for Normalized {
type Target = Content;
fn deref(&self) -> &Content {
&self.0
}
}
#[derive(Debug, Clone, PartialEq)]
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)]
pub enum MarkKind {
Strong,
Emph,
Underline,
Strike,
Code,
Link {
url: String,
},
Anchor {
id: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Island {
pub id: String,
pub island_type: IslandType,
pub props: JsonValue,
pub loss: Loss,
}
impl Island {
pub fn new(id: String, island_type: IslandType) -> 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, Copy, PartialEq, Eq)]
pub enum Loss {
Lossless,
Degraded,
Unrepresentable,
}
impl Loss {
pub const ALL: &'static [Loss] = &[Loss::Lossless, Loss::Degraded, Loss::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<Loss> {
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 tag(&self) -> &'static str {
match self {
MarkKind::Strong => "strong",
MarkKind::Emph => "emph",
MarkKind::Underline => "underline",
MarkKind::Strike => "strike",
MarkKind::Code => "code",
MarkKind::Link { .. } => "link",
MarkKind::Anchor { .. } => "anchor",
}
}
pub fn attrs(&self) -> Cow<'_, JsonValue> {
match self {
MarkKind::Strong
| MarkKind::Emph
| MarkKind::Underline
| MarkKind::Strike
| MarkKind::Code => Cow::Owned(JsonValue::Null),
MarkKind::Link { url } => Cow::Owned(bag([("url", url.as_str().into())])),
MarkKind::Anchor { id } => Cow::Owned(bag([("id", id.as_str().into())])),
}
}
pub fn sort_key(&self) -> (String, String) {
let attrs = self.attrs();
let attrs = if is_empty_bag(&attrs) {
String::new()
} else {
canonical_json_string(&attrs)
};
(self.tag().to_string(), attrs)
}
}
pub(crate) fn canonical_json_string(v: &JsonValue) -> String {
if is_value_key_sorted(v) {
return serde_json::to_string(v).unwrap_or_default();
}
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 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)]
pub enum Invariant {
CarriageReturn,
BidiControl(char),
LineSeparator(char),
IslandSlotMismatch { slots: usize, islands: usize },
LineCountMismatch { lines: usize, segments: usize },
MarkOutOfRange { start: Usv, end: Usv, len: Usv },
BadHeadingLevel(u8),
IslandIdCollision { id: String },
AnchorIdCollision { id: String },
NestingTooDeep {
line: usize,
depth: usize,
max: usize,
},
JsonTooDeep { what: &'static str, max: usize },
}
pub(crate) fn line_kind_contradicts_text(kind: &LineKind, seg: &str) -> bool {
match kind {
LineKind::Island => {
let mut chars = seg.chars();
!matches!((chars.next(), chars.next()), (Some(ISLAND_SLOT), None))
}
LineKind::Rule => !seg.is_empty(),
LineKind::Code { .. } => seg.contains(ISLAND_SLOT),
_ => false,
}
}
pub(crate) fn is_whole_line(chars: &[char], start: Usv, end: Usv) -> bool {
(start == 0 || chars.get(start - 1) == Some(&'\n')) && matches!(chars.get(end), None | Some('\n'))
}
pub(crate) fn inline_block_islands<'a>(
chars: &'a [char],
islands: &'a [Island],
) -> impl Iterator<Item = Usv> + 'a {
chars
.iter()
.enumerate()
.filter(|&(_, &c)| c == ISLAND_SLOT)
.zip(islands)
.filter(|&((at, _), island)| {
island.island_type.block_only() && !is_whole_line(chars, at, at + 1)
})
.map(|((at, _), _)| at)
}
fn fragment_line(line: &Line, span: std::ops::Range<Usv>, breaks: &[Usv], first: bool) -> Line {
if span.len() == 1 && breaks.binary_search(&span.start).is_ok() {
return Line {
kind: LineKind::Island,
containers: line.containers.clone(),
continues: false,
};
}
Line {
kind: line.kind.clone(),
containers: line.containers.clone(),
continues: first && line.continues,
}
}
fn island_line_kind(kind: &LineKind, seg: &str, island: Option<&Island>) -> Option<LineKind> {
if !matches!(kind, LineKind::Para | LineKind::Island) {
return None;
}
let mut chars = seg.chars();
if (chars.next(), chars.next()) != (Some(ISLAND_SLOT), None) {
return None;
}
let known = island?.island_type;
Some(if known.block_only() {
LineKind::Island
} else {
LineKind::Para
})
}
impl Content {
pub fn new(text: String, lines: Vec<Line>) -> Self {
Content {
text,
lines,
marks: Vec::new(),
islands: Vec::new(),
}
}
pub fn into_normalized(mut self) -> Normalized {
self.normalize();
Normalized(self)
}
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) {
canonicalize_containers(&mut self.lines);
let mut slot = 0usize;
for (line, seg) in self.lines.iter_mut().zip(self.text.split('\n')) {
if line_kind_contradicts_text(&line.kind, seg) {
line.kind = LineKind::Para;
}
if let Some(kind) = island_line_kind(&line.kind, seg, self.islands.get(slot)) {
line.kind = kind;
}
slot += seg.chars().filter(|&c| c == ISLAND_SLOT).count();
}
self.split_block_islands();
for i in 0..self.lines.len() {
if self.lines[i].continues
&& (i == 0
|| self.lines[i].containers != self.lines[i - 1].containers
|| !self.lines[i - 1].kind.takes_continuations())
{
self.lines[i].continues = false;
}
}
for island in &mut self.islands {
island.island_type.normalize_props(&mut island.props);
canonicalize_keys(&mut island.props);
}
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));
}
fn split_block_islands(&mut self) {
use crate::delta::{Delta, Op};
if !self.islands.iter().any(|i| i.island_type.block_only())
|| self.lines.len() != self.segment_count()
{
return;
}
let chars: Vec<char> = self.text.chars().collect();
let breaks: Vec<Usv> = inline_block_islands(&chars, &self.islands).collect();
if breaks.is_empty() {
return;
}
let mut cuts: Vec<Usv> = Vec::with_capacity(breaks.len() * 2);
for &at in &breaks {
if at > 0 && chars[at - 1] != '\n' {
cuts.push(at);
}
if chars.get(at + 1).is_some_and(|&c| c != '\n') {
cuts.push(at + 1);
}
}
cuts.dedup();
let mut lines = Vec::with_capacity(self.lines.len() + cuts.len());
let mut cut = cuts.iter().copied().peekable();
let mut pos = 0usize;
for (line, seg) in self.lines.iter().zip(self.text.split('\n')) {
let end = pos + seg.chars().count();
let mut start = pos;
let mut first = true;
while let Some(p) = cut.next_if(|&p| p < end) {
lines.push(fragment_line(line, start..p, &breaks, first));
(start, first) = (p, false);
}
lines.push(fragment_line(line, start..end, &breaks, first));
pos = end + 1;
}
let mut text = String::with_capacity(self.text.len() + cuts.len());
let mut at_cut = cuts.iter().copied().peekable();
for (i, &c) in chars.iter().enumerate() {
if at_cut.next_if_eq(&i).is_some() {
text.push('\n');
}
text.push(c);
}
let mut ops = Vec::with_capacity(cuts.len() * 2);
let mut last = 0usize;
for &p in &cuts {
ops.push(Op::Retain(p - last));
ops.push(Op::Insert("\n".to_string()));
last = p;
}
self.text = text;
self.lines = lines;
self.rebase_marks(&Delta { ops });
}
pub fn validate(&self) -> Result<(), Invariant> {
let mut slots = 0usize;
let mut newlines = 0usize;
let mut len: Usv = 0;
for c in self.text.chars() {
if c == '\r' {
return Err(Invariant::CarriageReturn);
}
if is_bidi_char(c) {
return Err(Invariant::BidiControl(c));
}
if is_line_separator(c) {
return Err(Invariant::LineSeparator(c));
}
if c == ISLAND_SLOT {
slots += 1;
}
if c == '\n' {
newlines += 1;
}
len += 1;
}
if slots != self.islands.len() {
return Err(Invariant::IslandSlotMismatch {
slots,
islands: self.islands.len(),
});
}
let segments = newlines + 1;
if self.lines.len() != segments {
return Err(Invariant::LineCountMismatch {
lines: self.lines.len(),
segments,
});
}
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 let MarkKind::Anchor { id } = &m.kind
&& (id.is_empty() || !seen_anchor_ids.insert(id.as_str()))
{
return Err(Invariant::AnchorIdCollision { id: id.clone() });
}
}
for (i, line) in self.lines.iter().enumerate() {
match &line.kind {
LineKind::Heading { level } if !(1..=6).contains(level) => {
return Err(Invariant::BadHeadingLevel(*level));
}
_ => {}
}
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")?;
for (text, marks) in island.island_type.cell_marks(&island.props) {
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,
});
}
}
}
}
Ok(())
}
}
struct Run {
raw: Container,
instance: u64,
ordinal: u64,
raw_ordinal: u64,
}
fn canonicalize_containers(lines: &mut [Line]) {
let mut state: Vec<Run> = Vec::new();
for line in lines.iter_mut() {
let depth_len = line.containers.len();
let mut opened_above = false;
for d in 0..depth_len {
let here = &line.containers[d];
let raw_ordinal = match here {
Container::ListItem { ordinal, .. } => *ordinal,
_ => 0,
};
let continues = !opened_above
&& state
.get(d)
.is_some_and(|r| r.raw.same_run(here) && r.raw.instance() == here.instance());
if continues {
let run = &mut state[d];
if raw_ordinal != run.raw_ordinal {
run.ordinal += 1;
run.raw_ordinal = raw_ordinal;
state.truncate(d + 1);
opened_above = true;
}
} else {
let instance = match state.get(d) {
Some(prev) if !opened_above && prev.raw.same_weld(here) => 1 - prev.instance,
_ => 0,
};
let raw = here.clone();
state.truncate(d);
state.push(Run {
raw,
instance,
ordinal: 0,
raw_ordinal,
});
opened_above = true;
}
let (ordinal, instance) = (state[d].ordinal, state[d].instance);
if let Container::ListItem { ordinal: o, .. } = &mut line.containers[d] {
*o = ordinal;
}
line.containers[d].set_instance(instance);
}
state.truncate(depth_len);
}
}
pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(String, String), Vec<(Usv, Usv)>> = BTreeMap::new();
let mut kind_of: BTreeMap<(String, 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.sort_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.sort_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 normalize_demotes_a_stranded_line_kind() {
for (text, kind) in [
("typed into a table line", LineKind::Island),
("", LineKind::Island),
("text on a rule line", LineKind::Rule),
] {
let mut rt = tagged(text, kind.clone());
rt.normalize();
assert_eq!(rt.lines[0].kind, LineKind::Para, "{text:?} as {kind:?}");
assert_eq!(rt.validate(), Ok(()));
}
let mut code = tagged(&format!("a{ISLAND_SLOT}b"), LineKind::Code { lang: None });
code.islands = vec![Island {
id: "isl-0".into(),
island_type: IslandType::Image,
props: serde_json::json!({"alt": "x", "url": "y.png"}),
loss: Loss::Lossless,
}];
for (kind, settles_to) in [
(LineKind::Code { lang: None }, LineKind::Para),
(LineKind::Para, LineKind::Para),
(LineKind::Heading { level: 1 }, LineKind::Heading { level: 1 }),
] {
let mut rt = code.clone();
rt.lines[0].kind = kind.clone();
rt.normalize();
assert_eq!(rt.lines[0].kind, settles_to, "a slot under {kind:?}");
assert_eq!(rt.validate(), Ok(()));
}
let mut rt = tagged(&ISLAND_SLOT.to_string(), LineKind::Island);
rt.islands = vec![table_island()];
rt.normalize();
assert_eq!(rt.lines[0].kind, LineKind::Island);
assert_eq!(rt.validate(), Ok(()));
assert_eq!(tagged("", LineKind::Rule).validate(), Ok(()));
}
fn table_island() -> Island {
Island::new("isl-0".into(), IslandType::Table).with_props(serde_json::json!({
"aligns": ["none"],
"header": [{"marks": [], "text": "h"}],
"rows": [[{"marks": [], "text": "c"}]],
}))
}
#[test]
fn an_island_alone_on_a_line_takes_the_kind_its_type_projects() {
let image = Island::new("isl-0".into(), IslandType::Image)
.with_props(serde_json::json!({"alt": "a", "url": "u"}));
for (island, canonical) in [
(table_island(), LineKind::Island),
(image, LineKind::Para),
] {
for stored in [LineKind::Para, LineKind::Island] {
let what = format!("{} as {stored:?}", island.island_type.as_str());
let rt = tagged(&ISLAND_SLOT.to_string(), stored)
.with_islands(vec![island.clone()])
.into_normalized();
assert_eq!(rt.validate(), Ok(()), "{what}");
assert_eq!(rt.lines[0].kind, canonical, "{what}");
let md = crate::export::to_markdown(&rt);
assert_eq!(
crate::import::from_markdown(&md).expect("re-imports"),
rt,
"{what} is not a fixed point: {md:?}"
);
}
}
}
#[test]
fn container_nesting_is_capped() {
let mut rt = tagged("hi", LineKind::Para);
rt.lines[0].containers = vec![Container::Quote { instance: 0 }; crate::MAX_NESTING_DEPTH];
assert_eq!(rt.validate(), Ok(()));
rt.lines[0].containers.push(Container::Quote { instance: 0 });
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("\u{fffc}", LineKind::Island);
rt.islands = vec![Island {
id: "i1".into(),
island_type: IslandType::Image,
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 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 continues_across_a_container_boundary_is_cleared() {
let mut rt = Content::new(
"a\nb".to_string(),
vec![
Line::new(LineKind::Para),
Line::new(LineKind::Para)
.with_containers(vec![Container::Quote { instance: 0 }])
.with_continues(true),
],
);
rt.normalize();
assert!(!rt.lines[1].continues, "normalize clears it");
assert_eq!(rt.validate(), Ok(()));
let li = |ordinal| {
vec![Container::ListItem {
ordered: false,
start: 1,
ordinal,
instance: 0,
}]
};
let mut rt = Content::new(
"a\nb".to_string(),
vec![
Line::new(LineKind::Para).with_containers(li(0)),
Line::new(LineKind::Para)
.with_containers(li(1))
.with_continues(true),
],
);
rt.normalize();
assert!(!rt.lines[1].continues);
let mut rt = Content::new(
"a\nb".to_string(),
vec![
Line::new(LineKind::Para).with_containers(li(0)),
Line::new(LineKind::Para)
.with_containers(li(0))
.with_continues(true),
],
);
rt.normalize();
assert!(rt.lines[1].continues, "a hard break inside one item survives");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn continues_after_a_single_line_block_is_cleared() {
let cases = [
(LineKind::Heading { level: 1 }, "a\nb", "# a\n\nb"),
(LineKind::Island, "\u{FFFC}\nb", "| h |\n| --- |\n| c |\n\nb"),
(LineKind::Rule, "\nb", "***\n\nb"),
];
for (kind, text, markdown) in cases {
let mut rt = Content::new(
text.to_string(),
vec![
Line::new(kind.clone()),
Line::new(LineKind::Para).with_continues(true),
],
)
.with_islands(match kind {
LineKind::Island => vec![Island::new("isl-0".into(), IslandType::Table)
.with_props(serde_json::json!({
"header": [{"text": "h", "marks": []}],
"rows": [[{"text": "c", "marks": []}]],
"aligns": ["none"],
}))],
_ => vec![],
});
rt.normalize();
assert!(!rt.lines[1].continues, "normalize clears it");
assert_eq!(rt.validate(), Ok(()));
assert_eq!(
crate::export::to_markdown(&rt.into_normalized()),
markdown,
"the continuation projects as the paragraph it is"
);
}
}
#[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: IslandType::Table,
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();
let canon = |rt: &Content| rt.clone().into_normalized().to_canonical_json();
assert_eq!(canon(&a), canon(&b));
let once = canon(&a);
a.normalize();
assert_eq!(canon(&a), 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
.into_normalized()
.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: IslandType::Table,
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: IslandType::Table,
props,
loss: Loss::Lossless,
}];
rt
}
fn cell(t: &str) -> serde_json::Value {
serde_json::json!({ "text": t, "marks": [] })
}
#[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 canon = |rt: &Content| rt.clone().into_normalized().to_canonical_json();
let once = canon(&rt);
rt.normalize();
assert_eq!(canon(&rt), 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_repaired() {
let mut rt = table_rt(serde_json::json!({
"header": "oops",
"aligns": [],
"rows": [],
}));
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: IslandType::Table,
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);
}
}