use crate::delta::{Assoc, Delta, Op};
use crate::model::{
is_whole_line, Container, Island, Line, LineKind, Mark,
MarkKind, Content, Usv, ISLAND_SLOT,
};
use crate::normalize::admit_char;
use crate::usv::char_to_byte;
use serde::Deserialize;
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq)]
pub enum MarkOp {
Add {
start: Usv,
end: Usv,
kind: MarkKind,
},
Remove {
start: Usv,
end: Usv,
kind: MarkKind,
},
RemoveAnchor { id: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum LineOp {
Split { at: Usv },
Join { line: usize },
SetKind { line: usize, kind: LineKind },
SetContainers {
line: usize,
containers: Vec<Container>,
},
SetContinues { line: usize, continues: bool },
}
#[derive(Debug, Clone, PartialEq)]
pub enum IslandOp {
Set { island: Island },
Insert { at: Usv, island: Island },
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChangeBundle {
pub delta: Delta,
pub island_ops: Vec<IslandOp>,
pub line_ops: Vec<LineOp>,
pub mark_ops: Vec<MarkOp>,
}
impl Default for ChangeBundle {
fn default() -> Self {
ChangeBundle {
delta: Delta { ops: Vec::new() },
island_ops: Vec::new(),
line_ops: Vec::new(),
mark_ops: Vec::new(),
}
}
}
impl ChangeBundle {
fn is_delta_only(&self) -> bool {
self.island_ops.is_empty() && self.line_ops.is_empty() && self.mark_ops.is_empty()
}
}
use crate::serial::{
container_from_authored_value, island_from_authored_value, line_kind_from_authored_value,
mark_from_authored_value, reject_unwritable_link_url, usv_from, ParseError,
};
use serde_json::Value;
pub fn mark_op_from_value(v: &Value) -> Result<MarkOp, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("mark op"))?;
match o.get("op").and_then(Value::as_str) {
Some("add") => {
reject_unwritable_link_url(v)?;
let mark = mark_from_authored_value(v)?;
Ok(MarkOp::Add {
start: mark.start,
end: mark.end,
kind: mark.kind,
})
}
Some("remove") => {
let mark = mark_from_authored_value(v)?;
Ok(MarkOp::Remove {
start: mark.start,
end: mark.end,
kind: mark.kind,
})
}
Some("removeAnchor") => Ok(MarkOp::RemoveAnchor {
id: o
.get("id")
.and_then(Value::as_str)
.ok_or(ParseError::Shape("removeAnchor id"))?
.to_string(),
}),
_ => Err(ParseError::Shape("mark op kind")),
}
}
pub fn line_op_from_value(v: &Value) -> Result<LineOp, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("line op"))?;
let line = || usv_from(o.get("line"), "line op line");
match o.get("op").and_then(Value::as_str) {
Some("split") => Ok(LineOp::Split {
at: usv_from(o.get("at"), "split at")?,
}),
Some("join") => Ok(LineOp::Join { line: line()? }),
Some("setKind") => Ok(LineOp::SetKind {
line: line()?,
kind: line_kind_from_authored_value(v)?,
}),
Some("setContainers") => Ok(LineOp::SetContainers {
line: line()?,
containers: o
.get("containers")
.and_then(Value::as_array)
.ok_or(ParseError::Shape("setContainers containers"))?
.iter()
.map(container_from_authored_value)
.collect::<Result<_, _>>()?,
}),
Some("setContinues") => Ok(LineOp::SetContinues {
line: line()?,
continues: o
.get("continues")
.and_then(Value::as_bool)
.ok_or(ParseError::Shape("setContinues continues"))?,
}),
_ => Err(ParseError::Shape("line op kind")),
}
}
pub fn island_op_from_value(v: &Value) -> Result<IslandOp, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("island op"))?;
let island = || island_from_authored_value(v);
match o.get("op").and_then(Value::as_str) {
Some("set") => Ok(IslandOp::Set { island: island()? }),
Some("insert") => Ok(IslandOp::Insert {
at: usv_from(o.get("at"), "island insert at")?,
island: island()?,
}),
_ => Err(ParseError::Shape("island op kind")),
}
}
pub fn change_bundle_from_value(v: &Value) -> Result<ChangeBundle, String> {
let obj = v
.as_object()
.ok_or("bundle must be an object { delta?, islandOps?, lineOps?, markOps? }")?;
let delta = match obj.get("delta") {
Some(Value::Null) | None => Delta { ops: Vec::new() },
Some(d) => Delta::deserialize(d).map_err(|e| format!("invalid delta: {e}"))?,
};
Ok(ChangeBundle {
delta,
island_ops: op_array(obj.get("islandOps"), island_op_from_value, "islandOps")?,
line_ops: op_array(obj.get("lineOps"), line_op_from_value, "lineOps")?,
mark_ops: op_array(obj.get("markOps"), mark_op_from_value, "markOps")?,
})
}
fn op_array<T>(
value: Option<&Value>,
convert: impl Fn(&Value) -> Result<T, ParseError>,
what: &str,
) -> Result<Vec<T>, String> {
let Some(value) = value.filter(|v| !v.is_null()) else {
return Ok(Vec::new());
};
let arr = value
.as_array()
.ok_or_else(|| format!("{what} must be an array"))?;
arr.iter()
.map(|v| convert(v).map_err(|e| format!("invalid {what}: {e}")))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyError {
MarkOutOfRange {
start: Usv,
end: Usv,
len: Usv,
},
LineOutOfRange {
line: usize,
lines: usize,
},
SplitPositionOutOfRange {
at: Usv,
len: Usv,
},
SplitAtNewline {
at: Usv,
},
LineCountMismatch {
lines: usize,
segments: usize,
},
DeltaBaseMismatch {
expected: usize,
actual: usize,
},
IslandSlotInInsert,
AnchorIdCollision { id: String },
EmptyAnchorId,
UnknownIslandId { id: String },
IslandIdCollision { id: String },
EmptyIslandId,
IslandInsertOutOfRange { at: Usv, len: Usv },
BlockIslandNotAlone { at: Usv },
NestingTooDeep {
line: usize,
depth: usize,
max: usize,
},
BadHeadingLevel {
line: usize,
level: u8,
},
}
impl Content {
pub fn apply_text_delta(&mut self, delta: &Delta) -> Result<(), ApplyError> {
self.apply_text_delta_inner(delta)?;
self.normalize();
Ok(())
}
fn apply_text_delta_inner(&mut self, delta: &Delta) -> Result<(), ApplyError> {
for op in &delta.ops {
if let Op::Insert(s) = op {
if s.contains(ISLAND_SLOT) {
return Err(ApplyError::IslandSlotInInsert);
}
}
}
let sanitized = sanitize_inserts(delta);
let delta = sanitized.as_ref();
let old_chars: Vec<char> = self.text.chars().collect();
let new_text = delta
.try_apply(&self.text)
.map_err(|e| ApplyError::DeltaBaseMismatch {
expected: e.expected,
actual: e.actual,
})?;
let old_lines = std::mem::take(&mut self.lines);
self.rebase_marks(delta);
let new_len = new_text.chars().count();
self.marks.retain(|m| {
m.start <= m.end
&& m.end <= new_len
&& (m.start < m.end || !m.kind.is_formatting())
});
self.text = new_text;
let old_islands = std::mem::take(&mut self.islands);
(self.lines, self.islands) = sync_for_delta(&old_chars, old_lines, old_islands, delta);
if self.lines.len() != self.segment_count() {
return Err(ApplyError::LineCountMismatch {
lines: self.lines.len(),
segments: self.segment_count(),
});
}
Ok(())
}
pub(crate) fn rebase_marks(&mut self, delta: &Delta) {
for m in &mut self.marks {
if m.start == m.end {
let p = delta.map_pos(m.start, Assoc::Before);
m.start = p;
m.end = p;
} else {
m.start = delta.map_pos(m.start, Assoc::After);
m.end = delta.map_pos(m.end, Assoc::Before);
}
}
}
pub fn apply_mark_ops(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
self.apply_mark_ops_inner(ops)?;
self.normalize();
Ok(())
}
fn apply_mark_ops_inner(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
let len = self.len_usv();
for op in ops {
match op {
MarkOp::Add { start, end, kind } => {
if *start > *end || *end > len {
return Err(ApplyError::MarkOutOfRange {
start: *start,
end: *end,
len,
});
}
if kind.is_formatting() && start == end {
return Err(ApplyError::MarkOutOfRange {
start: *start,
end: *end,
len,
});
}
if let MarkKind::Anchor { id } = kind {
if id.is_empty() {
return Err(ApplyError::EmptyAnchorId);
}
if self
.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id: aid } if aid == id))
{
return Err(ApplyError::AnchorIdCollision { id: id.clone() });
}
}
self.marks.push(Mark {
start: *start,
end: *end,
kind: kind.clone(),
});
}
MarkOp::Remove { start, end, kind } => {
if *start > *end || *end > len {
return Err(ApplyError::MarkOutOfRange {
start: *start,
end: *end,
len,
});
}
let mut next = Vec::with_capacity(self.marks.len());
for m in self.marks.drain(..) {
if m.kind != *kind || !ranges_overlap(m.start, m.end, *start, *end) {
next.push(m);
continue;
}
if !kind.is_formatting() {
continue;
}
if m.start < *start {
next.push(Mark {
start: m.start,
end: *start,
kind: m.kind.clone(),
});
}
if *end < m.end {
next.push(Mark {
start: *end,
end: m.end,
kind: m.kind.clone(),
});
}
}
self.marks = next;
}
MarkOp::RemoveAnchor { id } => {
self.marks
.retain(|m| !matches!(&m.kind, MarkKind::Anchor { id: aid } if aid == id));
}
}
}
Ok(())
}
pub fn apply_island_ops(&mut self, ops: &[IslandOp]) -> Result<(), ApplyError> {
self.apply_island_ops_inner(ops)?;
self.normalize();
Ok(())
}
fn apply_island_ops_inner(&mut self, ops: &[IslandOp]) -> Result<(), ApplyError> {
for op in ops {
match op {
IslandOp::Set { island } => {
let idx = self
.islands
.iter()
.position(|i| i.id == island.id)
.ok_or_else(|| ApplyError::UnknownIslandId {
id: island.id.clone(),
})?;
if island.island_type.block_only() {
let chars: Vec<char> = self.text.chars().collect();
let at = nth_slot(&chars, idx);
if !is_whole_line(&chars, at, at + 1) {
return Err(ApplyError::BlockIslandNotAlone { at });
}
}
self.islands[idx] = island.clone();
}
IslandOp::Insert { at, island } => {
if island.id.is_empty() {
return Err(ApplyError::EmptyIslandId);
}
if self.islands.iter().any(|i| i.id == island.id) {
return Err(ApplyError::IslandIdCollision {
id: island.id.clone(),
});
}
let chars: Vec<char> = self.text.chars().collect();
if *at > chars.len() {
return Err(ApplyError::IslandInsertOutOfRange {
at: *at,
len: chars.len(),
});
}
if island.island_type.block_only()
&& !is_whole_line(&chars, *at, *at)
{
return Err(ApplyError::BlockIslandNotAlone { at: *at });
}
let slot_idx = chars[..*at].iter().filter(|&&c| c == ISLAND_SLOT).count();
let byte = char_to_byte(&self.text, *at);
self.text.insert(byte, ISLAND_SLOT);
self.rebase_marks(&Delta {
ops: vec![Op::Retain(*at), Op::Insert(ISLAND_SLOT.to_string())],
});
self.islands.insert(slot_idx, island.clone());
}
}
}
Ok(())
}
pub fn apply_line_ops(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
self.apply_line_ops_inner(ops)?;
self.normalize();
Ok(())
}
fn apply_line_ops_inner(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
for op in ops {
match op {
LineOp::Split { at } => self.split_line(*at)?,
LineOp::Join { line } => self.join_line(*line)?,
LineOp::SetKind { line, kind } => {
if let LineKind::Heading { level } = kind
&& !(1..=6).contains(level)
{
return Err(ApplyError::BadHeadingLevel {
line: *line,
level: *level,
});
}
let line = self.line_mut(*line)?;
line.kind = kind.clone();
}
LineOp::SetContainers { line, containers } => {
if containers.len() > crate::MAX_NESTING_DEPTH {
return Err(ApplyError::NestingTooDeep {
line: *line,
depth: containers.len(),
max: crate::MAX_NESTING_DEPTH,
});
}
let line = self.line_mut(*line)?;
line.containers = containers.clone();
}
LineOp::SetContinues { line, continues } => {
let l = self.line_mut(*line)?;
l.continues = *continues;
}
}
}
Ok(())
}
pub fn apply_field_change(&mut self, bundle: &ChangeBundle) -> Result<(), ApplyError> {
if bundle.is_delta_only() {
return self.apply_text_delta(&bundle.delta);
}
let mut scratch = self.clone();
scratch.apply_text_channels(bundle)?;
scratch.apply_mark_ops_inner(&bundle.mark_ops)?;
scratch.normalize();
*self = scratch;
Ok(())
}
fn apply_text_channels(&mut self, bundle: &ChangeBundle) -> Result<(), ApplyError> {
self.apply_text_delta_inner(&bundle.delta)?;
self.apply_island_ops_inner(&bundle.island_ops)?;
self.apply_line_ops_inner(&bundle.line_ops)
}
pub fn map_marks(&self, bundle: &ChangeBundle) -> Result<Vec<Mark>, ApplyError> {
let mut scratch = self.clone();
scratch.apply_text_channels(bundle)?;
scratch.normalize();
Ok(scratch.marks)
}
fn line_mut(&mut self, line: usize) -> Result<&mut Line, ApplyError> {
let lines = self.lines.len();
self.lines
.get_mut(line)
.ok_or(ApplyError::LineOutOfRange { line, lines })
}
fn split_line(&mut self, at: Usv) -> Result<(), ApplyError> {
let char_indices: Vec<(usize, char)> = self.text.char_indices().collect();
let len = char_indices.len();
if at > len {
return Err(ApplyError::SplitPositionOutOfRange { at, len });
}
if at > 0 && char_indices[at - 1].1 == '\n' {
return Err(ApplyError::SplitAtNewline { at });
}
if at < len && char_indices[at].1 == '\n' {
return Err(ApplyError::SplitAtNewline { at });
}
let byte = char_indices.get(at).map_or(self.text.len(), |&(b, _)| b);
let line_idx = char_indices[..at].iter().filter(|&(_, c)| *c == '\n').count();
self.text.insert(byte, '\n');
self.rebase_marks(&Delta {
ops: vec![Op::Retain(at), Op::Insert("\n".to_string())],
});
let template = self
.lines
.get(line_idx)
.cloned()
.unwrap_or_else(|| Line::new(LineKind::Para));
let mut new_line = template;
new_line.continues = false;
self.lines.insert(line_idx + 1, new_line);
if self.lines.len() != self.segment_count() {
return Err(ApplyError::LineCountMismatch {
lines: self.lines.len(),
segments: self.segment_count(),
});
}
Ok(())
}
fn join_line(&mut self, line: usize) -> Result<(), ApplyError> {
if line + 1 >= self.lines.len() {
return Err(ApplyError::LineOutOfRange {
line,
lines: self.lines.len(),
});
}
let nl = newline_at_line_boundary(&self.text, line)?;
let byte = char_to_byte(&self.text, nl);
self.text.remove(byte);
self.rebase_marks(&Delta {
ops: vec![Op::Retain(nl), Op::Delete(1)],
});
self.lines.remove(line + 1);
if self.lines.len() != self.segment_count() {
return Err(ApplyError::LineCountMismatch {
lines: self.lines.len(),
segments: self.segment_count(),
});
}
Ok(())
}
}
fn ranges_overlap(a0: Usv, a1: Usv, b0: Usv, b1: Usv) -> bool {
a0 < b1 && b0 < a1
}
fn sanitize_inserts(delta: &Delta) -> Cow<'_, Delta> {
let needs_cleaning = delta
.ops
.iter()
.any(|op| matches!(op, Op::Insert(s) if s.chars().any(|c| admit_char(c) != Some(c))));
if !needs_cleaning {
return Cow::Borrowed(delta);
}
let ops = delta
.ops
.iter()
.map(|op| match op {
Op::Insert(s) => Op::Insert(s.chars().filter_map(admit_char).collect()),
other => other.clone(),
})
.collect();
Cow::Owned(Delta { ops })
}
fn sync_for_delta(
old_chars: &[char],
old_lines: Vec<Line>,
old_islands: Vec<Island>,
delta: &Delta,
) -> (Vec<Line>, Vec<Island>) {
let mut rest = old_lines.into_iter();
let mut lines: Vec<Line> = Vec::with_capacity(rest.len());
let mut cur: Option<Line> = rest.next();
let mut keep = vec![true; old_islands.len()];
let mut slot = 0usize;
let mut old = 0usize;
for op in &delta.ops {
match op {
Op::Retain(n) | Op::Delete(n) => {
let deleted = matches!(op, Op::Delete(_));
let end = old.saturating_add(*n).min(old_chars.len());
for &c in &old_chars[old..end] {
match c {
'\n' if deleted => {
rest.next();
}
'\n' => {
lines.extend(cur.take());
cur = rest.next();
}
ISLAND_SLOT => {
if deleted && let Some(k) = keep.get_mut(slot) {
*k = false;
}
slot += 1;
}
_ => {}
}
}
old = end;
}
Op::Insert(s) => {
for c in s.chars() {
if c == '\n' {
let mut new_line = match cur.take() {
Some(line) => {
let clone = line.clone();
lines.push(line);
clone
}
None => Line::new(LineKind::Para),
};
new_line.continues = false;
cur = Some(new_line);
}
}
}
}
}
lines.extend(cur);
lines.extend(rest);
let islands = old_islands
.into_iter()
.zip(keep)
.filter_map(|(island, keep)| keep.then_some(island))
.collect();
(lines, islands)
}
fn nth_slot(chars: &[char], n: usize) -> Usv {
chars
.iter()
.enumerate()
.filter(|&(_, &c)| c == ISLAND_SLOT)
.map(|(i, _)| i)
.nth(n)
.unwrap_or(chars.len())
}
fn newline_at_line_boundary(text: &str, line: usize) -> Result<Usv, ApplyError> {
let mut current = 0usize;
for (i, c) in text.chars().enumerate() {
if c == '\n' {
if current == line {
return Ok(i);
}
current += 1;
}
}
Err(ApplyError::LineOutOfRange {
line,
lines: text.chars().filter(|&c| c == '\n').count() + 1,
})
}
impl crate::model::Normalized {
fn seal(&mut self, applied: Result<(), ApplyError>) -> Result<(), ApplyError> {
if applied.is_err() {
self.as_content_mut().normalize();
}
applied
}
pub fn apply_text_delta(&mut self, delta: &Delta) -> Result<(), ApplyError> {
let applied = self.as_content_mut().apply_text_delta(delta);
self.seal(applied)
}
pub fn apply_mark_ops(&mut self, ops: &[MarkOp]) -> Result<(), ApplyError> {
let applied = self.as_content_mut().apply_mark_ops(ops);
self.seal(applied)
}
pub fn apply_island_ops(&mut self, ops: &[IslandOp]) -> Result<(), ApplyError> {
let applied = self.as_content_mut().apply_island_ops(ops);
self.seal(applied)
}
pub fn apply_line_ops(&mut self, ops: &[LineOp]) -> Result<(), ApplyError> {
let applied = self.as_content_mut().apply_line_ops(ops);
self.seal(applied)
}
pub fn apply_field_change(&mut self, bundle: &ChangeBundle) -> Result<(), ApplyError> {
let applied = self.as_content_mut().apply_field_change(bundle);
self.seal(applied)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::island::IslandType;
use crate::delta::diff;
use crate::import::from_markdown;
#[test]
fn mark_op_wire_decodes_each_variant() {
let cases = vec![
(
serde_json::json!({"op": "add", "start": 0, "end": 3, "type": "strong"}),
MarkOp::Add {
start: 0,
end: 3,
kind: MarkKind::Strong,
},
),
(
serde_json::json!({
"op": "add", "start": 1, "end": 2, "type": "link", "attrs": {"url": "https://x"},
}),
MarkOp::Add {
start: 1,
end: 2,
kind: MarkKind::Link {
url: "https://x".into(),
},
},
),
(
serde_json::json!({
"op": "remove", "start": 4, "end": 6, "type": "anchor", "attrs": {"id": "c1"},
}),
MarkOp::Remove {
start: 4,
end: 6,
kind: MarkKind::Anchor { id: "c1".into() },
},
),
(
serde_json::json!({"op": "removeAnchor", "id": "c2"}),
MarkOp::RemoveAnchor { id: "c2".into() },
),
];
for (v, op) in cases {
assert_eq!(mark_op_from_value(&v).unwrap(), op, "decode: {v}");
}
}
#[test]
fn line_op_wire_decodes_each_variant() {
let cases = vec![
(
serde_json::json!({"op": "split", "at": 5}),
LineOp::Split { at: 5 },
),
(
serde_json::json!({"op": "join", "line": 1}),
LineOp::Join { line: 1 },
),
(
serde_json::json!({"op": "setKind", "line": 0, "kind": "heading", "attrs": {"level": 2}}),
LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 2 },
},
),
(
serde_json::json!({
"op": "setContainers", "line": 2, "containers": [{"container": "quote"}],
}),
LineOp::SetContainers {
line: 2,
containers: vec![Container::Quote { instance: 0 }],
},
),
(
serde_json::json!({"op": "setContinues", "line": 1, "continues": true}),
LineOp::SetContinues {
line: 1,
continues: true,
},
),
(
serde_json::json!({"op": "setContinues", "line": 3, "continues": false}),
LineOp::SetContinues {
line: 3,
continues: false,
},
),
];
for (v, op) in cases {
assert_eq!(line_op_from_value(&v).unwrap(), op, "decode: {v}");
}
}
#[test]
fn op_wire_rejects_the_legacy_payload_spelling() {
let bad = serde_json::json!({
"op": "setKind", "line": 0, "kind": "heading", "level": 2,
});
assert!(matches!(line_op_from_value(&bad), Err(ParseError::Shape(_))));
let bad = serde_json::json!({
"op": "setContainers", "line": 0,
"containers": [{"container": "list_item", "ordered": true}],
});
assert!(matches!(line_op_from_value(&bad), Err(ParseError::Shape(_))));
let bad = serde_json::json!({
"op": "add", "start": 0, "end": 1, "type": "link", "url": "u",
});
assert!(matches!(mark_op_from_value(&bad), Err(ParseError::Shape(_))));
for ok in [
serde_json::json!({"op": "setKind", "line": 0, "kind": "heading", "attrs": {"level": 2}}),
serde_json::json!({"op": "setKind", "line": 0, "kind": "para", "attrs": {"tone": "warn"}}),
] {
assert!(line_op_from_value(&ok).is_ok(), "rejected: {ok}");
}
}
#[test]
fn op_wire_refuses_an_unknown_name() {
let cases: [(Value, &str, &str); 5] = [
(
serde_json::json!({"op": "setKind", "line": 0, "kind": "callout"}),
"line kind",
"callout",
),
(
serde_json::json!({"op": "setContainers", "line": 0,
"containers": [{"container": "indent", "instance": 0}]}),
"container",
"indent",
),
(
serde_json::json!({"op": "add", "start": 0, "end": 1, "type": "highlight"}),
"mark type",
"highlight",
),
(
serde_json::json!({"op": "insert", "at": 0, "id": "i1",
"type": "widget", "loss": "lossless", "props": {}}),
"island type",
"widget",
),
(
serde_json::json!({"op": "insert", "at": 0, "id": "i1",
"type": "table", "loss": "partial", "props": {}}),
"island loss",
"partial",
),
];
for (op, axis, name) in cases {
let decode = match axis {
"line kind" | "container" => line_op_from_value(&op),
"mark type" => mark_op_from_value(&op).map(|_| unreachable!()),
_ => island_op_from_value(&op).map(|_| unreachable!()),
};
assert_eq!(
decode.unwrap_err(),
ParseError::UnknownName {
axis,
name: name.to_string()
},
"op wire accepted {axis} {name:?}"
);
}
}
#[test]
fn a_url_the_projection_cannot_write_is_refused_where_an_op_stores_it() {
let link = |op: &str, url: &str| {
serde_json::json!({"op": op, "start": 0, "end": 1, "type": "link", "attrs": {"url": url}})
};
for url in ["a\nb", "a\rb"] {
assert!(
matches!(
mark_op_from_value(&link("add", url)),
Err(ParseError::Shape(_))
),
"accepted: {url:?}"
);
assert!(
mark_op_from_value(&link("remove", url)).is_ok(),
"unremovable: {url:?}"
);
}
let image = |url: &str| {
serde_json::json!({
"op": "insert", "at": 0, "id": "i1", "type": "image",
"props": {"alt": "a", "url": url},
})
};
assert!(matches!(
island_op_from_value(&image("u\nv")),
Err(ParseError::Shape(_))
));
assert!(
island_op_from_value(&image("u v")).is_ok(),
"a space angle-wraps and round-trips"
);
}
#[test]
fn delta_serde_shape() {
let d = Delta {
ops: vec![Op::Retain(2), Op::Insert("hi".into()), Op::Delete(1)],
};
let v = serde_json::to_value(&d).unwrap();
assert_eq!(
v,
serde_json::json!({"ops": [{"retain": 2}, {"insert": "hi"}, {"delete": 1}]})
);
assert_eq!(serde_json::from_value::<Delta>(v).unwrap(), d);
}
#[test]
fn apply_text_delta_rebases_marks() {
let mut rt = from_markdown("hello").unwrap().into_content();
rt.marks.push(Mark {
start: 1,
end: 4,
kind: MarkKind::Strong,
});
let mut rt = rt.into_normalized();
let d = diff("hello", "hXello");
rt.apply_text_delta(&d).unwrap();
let strong = rt
.marks
.iter()
.find(|m| matches!(m.kind, MarkKind::Strong))
.unwrap();
assert_eq!((strong.start, strong.end), (2, 5));
assert_eq!(rt.text, "hXello");
}
fn anchored(text: &str, at: Usv) -> crate::model::Normalized {
let mut rt = from_markdown(text).unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: at,
end: at,
kind: MarkKind::Anchor { id: "a1".into() },
}])
.unwrap();
rt
}
fn anchor_at(rt: &Content) -> (Usv, Usv) {
let m = rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "a1"))
.expect("the anchor survives");
(m.start, m.end)
}
fn strong_at(rt: &Content) -> (Usv, Usv) {
let m = rt
.marks
.iter()
.find(|m| matches!(m.kind, MarkKind::Strong))
.expect("the mark survives");
(m.start, m.end)
}
#[test]
fn an_insert_at_a_zero_width_marks_position_leaves_it_put() {
let d = diff("hello world", "hello Xworld");
assert_eq!(d.map_pos(6, Assoc::After), 7, "the answer not taken");
let mut via_delta = anchored("hello world", 6);
via_delta.apply_text_delta(&d).unwrap();
assert_eq!(anchor_at(&via_delta), (6, 6));
let mut via_island = anchored("hello world", 6);
via_island
.apply_island_ops(&[IslandOp::Insert {
at: 6,
island: image("i1"),
}])
.unwrap();
assert_eq!(anchor_at(&via_island), (6, 6));
let mut via_line = anchored("hello world", 6);
via_line.apply_line_ops(&[LineOp::Split { at: 6 }]).unwrap();
assert_eq!(anchor_at(&via_line), (6, 6));
}
#[test]
fn an_insert_at_a_range_marks_edge_stays_outside_the_span() {
let mut at_start = from_markdown("hello world").unwrap();
at_start
.apply_mark_ops(&[MarkOp::Add {
start: 6,
end: 11,
kind: MarkKind::Strong,
}])
.unwrap();
at_start
.apply_text_delta(&diff("hello world", "hello Xworld"))
.unwrap();
assert_eq!(strong_at(&at_start), (7, 12));
let mut at_end = from_markdown("hello world").unwrap();
at_end
.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Strong,
}])
.unwrap();
at_end
.apply_text_delta(&diff("hello world", "helloX world"))
.unwrap();
assert_eq!(strong_at(&at_end), (0, 5));
let mut at_end_island = from_markdown("hello world").unwrap();
at_end_island
.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Strong,
}])
.unwrap();
at_end_island
.apply_island_ops(&[IslandOp::Insert {
at: 5,
island: image("i1"),
}])
.unwrap();
assert_eq!(strong_at(&at_end_island), (0, 5));
}
#[test]
fn map_marks_reports_where_apply_field_change_puts_them() {
let mut rt = anchored("hello world", 6);
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Strong,
}])
.unwrap();
let bundle = ChangeBundle {
delta: diff("hello world", "hello Xworld"),
island_ops: vec![IslandOp::Insert {
at: 6,
island: image("i1"),
}],
line_ops: vec![LineOp::Split { at: 6 }],
mark_ops: Vec::new(),
};
let predicted = rt.map_marks(&bundle).unwrap();
rt.apply_field_change(&bundle).unwrap();
assert_eq!(predicted, rt.marks);
assert_eq!(anchor_at(&rt), (6, 6), "the anchor never left its position");
}
#[test]
fn map_marks_reports_the_union_a_move_makes_adjacent() {
for line_ops in [
Vec::new(),
vec![LineOp::SetKind {
line: 0,
kind: LineKind::Para,
}],
] {
let mut rt = from_markdown("ab cd").unwrap();
rt.apply_mark_ops(&[
MarkOp::Add {
start: 0,
end: 2,
kind: MarkKind::Strong,
},
MarkOp::Add {
start: 3,
end: 5,
kind: MarkKind::Strong,
},
])
.unwrap();
let bundle = ChangeBundle {
delta: diff("ab cd", "abcd"),
line_ops,
..Default::default()
};
let predicted = rt.map_marks(&bundle).unwrap();
rt.apply_field_change(&bundle).unwrap();
assert_eq!(predicted, rt.marks);
assert_eq!(strong_at(&rt), (0, 4), "the two runs are one");
}
}
#[test]
fn map_marks_reports_an_out_of_bounds_bundle_without_touching_the_content() {
let rt = anchored("hello world", 6);
let before = rt.clone();
let err = rt
.map_marks(&ChangeBundle {
island_ops: vec![IslandOp::Insert {
at: 99,
island: image("i1"),
}],
..Default::default()
})
.unwrap_err();
assert!(matches!(err, ApplyError::IslandInsertOutOfRange { .. }));
assert_eq!(rt.marks, before.marks);
assert_eq!(rt.text, before.text);
}
#[test]
fn apply_text_delta_pads_short_prepend() {
let mut rt = from_markdown("hello").unwrap();
rt.apply_text_delta(&Delta {
ops: vec![Op::Insert("NEW ".into())],
})
.unwrap();
assert_eq!(rt.text, "NEW hello");
}
#[test]
fn apply_text_delta_rejects_over_long_delta() {
let mut rt = from_markdown("hi").unwrap();
assert!(matches!(
rt.apply_text_delta(&Delta {
ops: vec![Op::Retain(99)],
}),
Err(ApplyError::DeltaBaseMismatch { .. })
));
assert_eq!(rt.text, "hi");
}
#[test]
fn apply_field_change_rejects_a_bundle_whose_retains_overflow() {
let bundle = change_bundle_from_value(&serde_json::json!({
"delta": { "ops": [{ "retain": usize::MAX }, { "retain": 2 }] }
}))
.unwrap();
let mut rt = from_markdown("hi").unwrap();
assert!(matches!(
rt.apply_field_change(&bundle),
Err(ApplyError::DeltaBaseMismatch { .. })
));
assert_eq!(rt.text, "hi");
}
#[test]
fn apply_mark_ops_remove_punches_hole() {
let mut rt = from_markdown("abcdef").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 6,
kind: MarkKind::Strong,
}])
.unwrap();
rt.apply_mark_ops(&[MarkOp::Remove {
start: 2,
end: 4,
kind: MarkKind::Strong,
}])
.unwrap();
let strong: Vec<_> = rt
.marks
.iter()
.filter(|m| matches!(m.kind, MarkKind::Strong))
.map(|m| (m.start, m.end))
.collect();
assert_eq!(strong, vec![(0, 2), (4, 6)]);
}
#[test]
fn apply_mark_ops_remove_at_edge_leaves_no_zero_width() {
let mut rt = from_markdown("abcdef").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 6,
kind: MarkKind::Strong,
}])
.unwrap();
rt.apply_mark_ops(&[MarkOp::Remove {
start: 0,
end: 2,
kind: MarkKind::Strong,
}])
.unwrap();
let strong: Vec<_> = rt
.marks
.iter()
.filter(|m| matches!(m.kind, MarkKind::Strong))
.map(|m| (m.start, m.end))
.collect();
assert_eq!(strong, vec![(2, 6)]);
}
#[test]
fn apply_mark_ops_remove_covering_range_drops_mark() {
let mut rt = from_markdown("abcdef").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 2,
end: 4,
kind: MarkKind::Emph,
}])
.unwrap();
rt.apply_mark_ops(&[MarkOp::Remove {
start: 0,
end: 6,
kind: MarkKind::Emph,
}])
.unwrap();
assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
}
#[test]
fn apply_mark_ops_remove_non_formatting_drops_whole() {
let anchor = || MarkKind::Anchor { id: "a".into() };
let mut rt = from_markdown("abcdef").unwrap().into_content();
rt.marks.push(Mark {
start: 0,
end: 6,
kind: anchor(),
});
let mut rt = rt.into_normalized();
rt.apply_mark_ops(&[MarkOp::Remove {
start: 2,
end: 4,
kind: anchor(),
}])
.unwrap();
assert!(!rt
.marks
.iter()
.any(|m| matches!(m.kind, MarkKind::Anchor { .. })));
}
#[test]
fn a_failed_op_list_leaves_the_token_canonical() {
let mut rt = from_markdown("**a**b").unwrap();
let ops = [
MarkOp::Add {
start: 0,
end: 2,
kind: MarkKind::Strong,
},
MarkOp::Add {
start: 0,
end: 99,
kind: MarkKind::Strong,
},
];
assert!(rt.apply_mark_ops(&ops).is_err());
assert_eq!((*rt).clone().into_normalized(), rt);
}
#[test]
fn line_op_split_and_join() {
let mut rt = from_markdown("onetwo").unwrap();
rt.apply_line_ops(&[LineOp::Split { at: 3 }]).unwrap();
assert_eq!(rt.text, "one\ntwo");
assert_eq!(rt.lines.len(), 2);
rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
assert_eq!(rt.text, "onetwo");
assert_eq!(rt.lines.len(), 1);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn line_op_set_kind() {
let mut rt = from_markdown("title").unwrap();
rt.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 2 },
}])
.unwrap();
assert!(matches!(rt.lines[0].kind, LineKind::Heading { level: 2 }));
}
#[test]
fn line_op_set_kind_over_contradicting_text_settles_to_what_the_text_spells() {
for kind in [LineKind::Island, LineKind::Rule] {
let mut rt = from_markdown("hello world").unwrap();
assert_eq!(rt.apply_line_ops(&[LineOp::SetKind { line: 0, kind }]), Ok(()));
assert_eq!(rt.text, "hello world");
assert_eq!(rt.lines[0].kind, LineKind::Para);
assert_eq!(rt.validate(), Ok(()));
}
let mut tbl = from_markdown("| a | b |\n|---|---|\n| 1 | 2 |").unwrap();
assert_eq!(
tbl.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Code { lang: None },
}]),
Ok(())
);
assert_eq!(tbl.lines[0].kind, LineKind::Island);
let mut heading = from_markdown("# a").unwrap();
assert_eq!(
heading.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Island,
}]),
Ok(())
);
assert_eq!(heading.lines[0].kind, LineKind::Para);
assert_eq!(crate::export::to_markdown(&heading), "a");
}
#[test]
fn set_continues_lands_only_where_a_block_can_continue() {
let mut rt = from_markdown("- a\n\npara").unwrap();
assert_ne!(rt.lines[0].containers, rt.lines[1].containers);
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContinues {
line: 1,
continues: true
}]),
Ok(())
);
assert!(!rt.lines[1].continues, "the crossing is cleared");
let mut rt = from_markdown("- a\n\n b").unwrap();
assert_eq!(rt.lines[0].containers, rt.lines[1].containers);
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContinues {
line: 1,
continues: true
}]),
Ok(())
);
assert!(rt.lines[1].continues);
for markdown in ["# a\n\nb", "| h |\n| --- |\n| c |\n\nb", "***\n\nb"] {
let mut rt = from_markdown(markdown).unwrap();
let line = rt.lines.len() - 1;
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContinues {
line,
continues: true
}]),
Ok(()),
"{markdown}"
);
assert!(!rt.lines[line].continues, "{markdown}");
assert_eq!(crate::export::to_markdown(&rt), markdown, "{markdown}");
}
let mut rt = from_markdown("a\\\nb").unwrap();
assert!(rt.lines[1].continues, "a hard break is a continuation");
assert_eq!(
rt.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 1 },
}]),
Ok(())
);
assert!(!rt.lines[1].continues);
assert_eq!(rt.validate(), Ok(()));
assert_eq!(crate::export::to_markdown(&rt), "# a\n\nb");
}
#[test]
fn join_across_two_paths_leaves_a_valid_content() {
let mut rt = from_markdown("- a\n\npara\\\nbroken").unwrap();
let seam = rt
.lines
.iter()
.position(|l| l.continues)
.expect("the hard break is there");
assert!(rt.apply_line_ops(&[LineOp::Join { line: seam - 2 }]).is_ok());
assert_eq!(rt.validate(), Ok(()), "the join left a storable content");
let mut again = rt.clone().into_content();
again.normalize();
assert_eq!(&again, &*rt, "the join left a repairable shape");
}
#[test]
fn line_op_set_containers_is_depth_capped() {
let mut rt = from_markdown("hi").unwrap();
let deep = vec![Container::Quote { instance: 0 }; crate::MAX_NESTING_DEPTH + 1];
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContainers {
line: 0,
containers: deep,
}]),
Err(ApplyError::NestingTooDeep {
line: 0,
depth: crate::MAX_NESTING_DEPTH + 1,
max: crate::MAX_NESTING_DEPTH,
})
);
assert!(rt.lines[0].containers.is_empty());
}
#[test]
fn line_op_set_kind_range_checks_the_heading_level() {
let mut rt = from_markdown("t").unwrap();
assert_eq!(
rt.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 9 },
}]),
Err(ApplyError::BadHeadingLevel { line: 0, level: 9 })
);
assert_eq!(rt.validate(), Ok(()));
assert!(rt
.apply_line_ops(&[LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 6 },
}])
.is_ok());
}
#[test]
fn line_op_set_continues_sets_and_clears() {
let mut rt = from_markdown("one two").unwrap();
rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
assert!(!rt.lines[1].continues, "delta-split newline is a new block");
rt.apply_line_ops(&[LineOp::SetContinues {
line: 1,
continues: true,
}])
.unwrap();
assert!(rt.lines[1].continues);
assert_eq!(rt.validate(), Ok(()));
assert_eq!(
crate::export::to_markdown(&rt).matches("\n\n").count(),
0,
"a within-block hard break is not a paragraph boundary"
);
rt.apply_line_ops(&[LineOp::SetContinues {
line: 1,
continues: false,
}])
.unwrap();
assert!(!rt.lines[1].continues);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn line_op_set_continues_on_the_first_line_clears() {
let mut rt = from_markdown("one two").unwrap();
rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
let before = rt.clone();
for continues in [true, false] {
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContinues { line: 0, continues }]),
Ok(())
);
assert!(!rt.lines[0].continues);
assert_eq!(rt, before, "the first line's flag reaches no projection");
assert_eq!(rt.validate(), Ok(()));
}
}
fn island(id: &str) -> Island {
Island {
id: id.into(),
island_type: IslandType::Image,
props: serde_json::json!({}),
loss: crate::model::Loss::Lossless,
}
}
#[test]
fn delete_one_of_two_slots_removes_the_matching_island() {
let mut rt = Content::empty();
rt.text = format!("{ISLAND_SLOT}x{ISLAND_SLOT}");
rt.lines = vec![Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
}];
rt.islands = vec![island("first"), island("second")];
assert_eq!(rt.validate(), Ok(()));
let d = Delta {
ops: vec![Op::Delete(1), Op::Retain(2)],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, format!("x{ISLAND_SLOT}"));
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].id, "second");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn insert_bidi_control_is_stripped() {
let mut rt = from_markdown("ab").unwrap();
let d = Delta {
ops: vec![
Op::Retain(1),
Op::Insert("\u{202E}".into()),
Op::Retain(1),
],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, "ab");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn insert_line_separator_is_spaced() {
for sep in ['\u{000B}', '\u{000C}', '\u{0085}', '\u{2028}', '\u{2029}'] {
let mut rt = from_markdown("ab").unwrap();
let d = Delta {
ops: vec![Op::Retain(2), Op::Insert(format!("{sep}- item"))],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, "ab - item", "for {sep:?}");
assert_eq!(rt.lines.len(), 1);
assert_eq!(rt.validate(), Ok(()));
}
}
#[test]
fn insert_crlf_keeps_the_newline_and_splits() {
let mut rt = from_markdown("ab").unwrap();
let d = Delta {
ops: vec![Op::Retain(1), Op::Insert("\r\n".into()), Op::Retain(1)],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, "a\nb");
assert_eq!(rt.lines.len(), 2);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn insert_of_clean_text_is_not_reallocated() {
let d = Delta {
ops: vec![Op::Retain(1), Op::Insert("clean\n".into()), Op::Retain(1)],
};
assert!(matches!(sanitize_inserts(&d), Cow::Borrowed(_)));
}
fn mark_bundle(delta: Delta, mark_ops: Vec<MarkOp>) -> ChangeBundle {
ChangeBundle {
delta,
mark_ops,
..Default::default()
}
}
fn island_bundle(island_ops: Vec<IslandOp>) -> ChangeBundle {
ChangeBundle {
island_ops,
..Default::default()
}
}
fn table_props(header: &str, cell: &str) -> serde_json::Value {
serde_json::json!({
"header": [{ "text": header, "marks": [] }],
"rows": [[{ "text": cell, "marks": [] }]],
"aligns": ["none"],
})
}
fn image(id: &str) -> Island {
Island::new(id.into(), IslandType::Image)
.with_props(serde_json::json!({ "url": "u", "alt": "a" }))
}
#[test]
fn island_op_wire_decodes_each_variant() {
let island = Island::new("isl-0".into(), IslandType::Table)
.with_props(table_props("H", "a"))
.with_loss(crate::model::Loss::Degraded);
let cases = vec![
(
serde_json::json!({
"op": "set", "id": "isl-0", "type": "table",
"props": table_props("H", "a"), "loss": "degraded",
}),
IslandOp::Set {
island: island.clone(),
},
),
(
serde_json::json!({
"op": "insert", "at": 7, "id": "isl-0", "type": "table",
"props": table_props("H", "a"), "loss": "degraded",
}),
IslandOp::Insert { at: 7, island },
),
];
for (v, op) in cases {
assert_eq!(island_op_from_value(&v).unwrap(), op, "decode: {v}");
}
}
#[test]
fn island_set_edits_props_and_keeps_the_field_anchors() {
let mut rt = from_markdown("intro\n\n| H |\n| --- |\n| a |").unwrap();
assert_eq!(rt.islands.len(), 1, "one table island");
let id = rt.islands[0].id.clone();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Anchor { id: "c1".into() },
}])
.unwrap();
rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
island: Island::new(id.clone(), IslandType::Table).with_props(table_props("H", "b")),
}]))
.unwrap();
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].id, id, "the id is target and stored value");
assert_eq!(rt.islands[0].props, table_props("H", "b"));
let anchor = rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("the anchor above the table survives the island edit");
assert_eq!((anchor.start, anchor.end), (0, 5));
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn island_set_rejects_an_unknown_id() {
let mut rt = from_markdown("| H |\n| --- |\n| a |").unwrap();
let before = rt.clone();
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
island: Island::new("isl-nope".into(), IslandType::Table)
.with_props(table_props("H", "b")),
}])),
Err(ApplyError::UnknownIslandId {
id: "isl-nope".into()
})
);
assert_eq!(rt, before);
}
#[test]
fn island_insert_adds_the_slot_and_its_entry() {
let mut rt = from_markdown("ab").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 1,
kind: MarkKind::Anchor { id: "c1".into() },
}])
.unwrap();
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 1,
island: Island::new("isl-new".into(), IslandType::Image)
.with_props(serde_json::json!({ "url": "u", "alt": "a" })),
}]))
.unwrap();
assert_eq!(rt.text, format!("a{ISLAND_SLOT}b"));
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].id, "isl-new");
assert_eq!(rt.validate(), Ok(()), "slot count matches the island list");
let anchor = rt
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("anchor survives");
assert_eq!((anchor.start, anchor.end), (0, 1));
}
#[test]
fn island_inserts_apply_in_sequence() {
let mut rt = from_markdown("xabc").unwrap();
rt.apply_field_change(&ChangeBundle {
delta: diff("xabc", "abc"),
island_ops: vec![
IslandOp::Insert {
at: 1,
island: image("isl-b"),
},
IslandOp::Insert {
at: 3,
island: image("isl-c"),
},
IslandOp::Insert {
at: 1,
island: image("isl-a"),
},
],
..Default::default()
})
.unwrap();
assert_eq!(
rt.text,
format!("a{ISLAND_SLOT}{ISLAND_SLOT}b{ISLAND_SLOT}c")
);
let ids: Vec<&str> = rt.islands.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, ["isl-a", "isl-b", "isl-c"], "slot order, not emission");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn slot_bearing_splice_splits_into_delta_and_insert() {
let mut rt = from_markdown("ab").unwrap();
let before = rt.clone();
let paste = format!("x{ISLAND_SLOT}y");
assert_eq!(
rt.apply_field_change(&ChangeBundle {
delta: Delta {
ops: vec![Op::Retain(1), Op::Insert(paste)],
},
..Default::default()
}),
Err(ApplyError::IslandSlotInInsert)
);
assert_eq!(rt, before, "the refusal commits nothing");
rt.apply_field_change(&ChangeBundle {
delta: Delta {
ops: vec![Op::Retain(1), Op::Insert("xy".into())],
},
island_ops: vec![IslandOp::Insert {
at: 2,
island: image("isl-p"),
}],
..Default::default()
})
.unwrap();
assert_eq!(rt.text, format!("ax{ISLAND_SLOT}yb"));
assert_eq!(rt.islands[0].id, "isl-p");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn block_island_restore_retags_its_line() {
let mut rt = from_markdown("intro").unwrap();
rt.apply_field_change(&ChangeBundle {
delta: diff("intro", "intro\n"),
island_ops: vec![IslandOp::Insert {
at: 6,
island: Island::new("isl-a".into(), IslandType::Table)
.with_props(table_props("H", "a")),
}],
line_ops: vec![LineOp::SetKind {
line: 1,
kind: LineKind::Island,
}],
..Default::default()
})
.unwrap();
let before = rt.clone();
let held = rt.islands[0].clone();
assert_eq!(before.lines[1].kind, LineKind::Island);
rt.apply_field_change(&ChangeBundle {
delta: diff(&before.text, "intro\n"),
..Default::default()
})
.unwrap();
assert!(rt.islands.is_empty());
assert_eq!(rt.lines[1].kind, LineKind::Para, "demoted, not failed");
rt.apply_field_change(&ChangeBundle {
island_ops: vec![IslandOp::Insert { at: 6, island: held }],
line_ops: vec![LineOp::SetKind {
line: 1,
kind: LineKind::Island,
}],
..Default::default()
})
.unwrap();
assert_eq!(rt, before, "same content, original id and kind included");
}
#[test]
fn a_block_only_island_lands_only_on_a_line_of_its_own() {
let table = |id: &str| {
Island::new(id.into(), IslandType::Table).with_props(table_props("H", "a"))
};
let mut rt = from_markdown("ab").unwrap();
let before = rt.clone();
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 1,
island: table("isl-t"),
}])),
Err(ApplyError::BlockIslandNotAlone { at: 1 })
);
assert_eq!(rt, before, "the refusal commits nothing");
rt.apply_field_change(&ChangeBundle {
delta: diff("ab", "ab\n"),
island_ops: vec![IslandOp::Insert {
at: 3,
island: table("isl-t"),
}],
line_ops: vec![LineOp::SetKind {
line: 1,
kind: LineKind::Island,
}],
..Default::default()
})
.unwrap();
assert_eq!(rt.validate(), Ok(()));
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 1,
island: image("isl-i"),
}]))
.unwrap();
assert_eq!(rt.text, format!("a{ISLAND_SLOT}b\n{ISLAND_SLOT}"));
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
island: table("isl-i"),
}])),
Err(ApplyError::BlockIslandNotAlone { at: 1 })
);
rt.apply_field_change(&island_bundle(vec![IslandOp::Set {
island: table("isl-t"),
}]))
.expect("the block island's own slot is a whole line");
}
#[test]
fn a_join_onto_a_block_island_line_is_undone_by_the_mint() {
let mut rt = from_markdown("ab\n\n| H |\n| --- |\n| a |").unwrap();
let before = rt.clone();
rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
assert_eq!(rt.validate(), Ok(()));
assert_eq!(rt, before, "the slot stayed in the paragraph");
}
#[test]
fn island_insert_id_and_position_rules() {
let mut rt = from_markdown("ab").unwrap();
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 1,
island: image(""),
}])),
Err(ApplyError::EmptyIslandId)
);
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 9,
island: image("isl-a"),
}])),
Err(ApplyError::IslandInsertOutOfRange { at: 9, len: 2 })
);
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 1,
island: image("isl-a"),
}]))
.unwrap();
assert_eq!(
rt.apply_field_change(&island_bundle(vec![IslandOp::Insert {
at: 0,
island: image("isl-a"),
}])),
Err(ApplyError::IslandIdCollision { id: "isl-a".into() })
);
}
#[test]
fn block_island_lands_in_one_bundle() {
let mut rt = from_markdown("intro").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Anchor { id: "c1".into() },
}])
.unwrap();
rt.apply_field_change(&ChangeBundle {
delta: diff("intro", "intro\n"),
island_ops: vec![IslandOp::Insert {
at: 6,
island: Island::new("isl-t".into(), IslandType::Table)
.with_props(table_props("H", "a")),
}],
line_ops: vec![LineOp::SetKind {
line: 1,
kind: LineKind::Island,
}],
..Default::default()
})
.unwrap();
assert_eq!(rt.text, format!("intro\n{ISLAND_SLOT}"));
assert_eq!(rt.lines[1].kind, LineKind::Island);
assert_eq!(rt.validate(), Ok(()));
assert!(rt
.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")));
assert!(
crate::export::to_markdown(&rt).contains("| H |"),
"the block island projects as a pipe table"
);
}
#[test]
fn apply_field_change_bundle_order() {
let mut rt = from_markdown("abc").unwrap();
let d = diff("abc", "abXc");
rt.apply_field_change(&mark_bundle(
d,
vec![MarkOp::Add {
start: 3,
end: 4,
kind: MarkKind::Strong,
}],
))
.unwrap();
let strong = rt
.marks
.iter()
.find(|m| matches!(m.kind, MarkKind::Strong))
.unwrap();
assert_eq!((strong.start, strong.end), (3, 4));
assert_eq!(rt.text, "abXc");
}
#[test]
fn apply_field_change_is_all_or_nothing() {
let mut rt = from_markdown("abc").unwrap();
let before = rt.clone();
let d = diff("abc", "abXc");
let err = rt.apply_field_change(&mark_bundle(
d,
vec![
MarkOp::Add {
start: 0,
end: 2,
kind: MarkKind::Strong,
},
MarkOp::Add {
start: 99,
end: 100,
kind: MarkKind::Emph,
},
],
));
assert!(matches!(err, Err(ApplyError::MarkOutOfRange { .. })));
assert_eq!(rt, before, "failed bundle must not mutate the content");
}
#[test]
fn add_anchor_id_uniqueness() {
let anchor = |id: &str| MarkKind::Anchor { id: id.into() };
let add = |start, end, id: &str| MarkOp::Add {
start,
end,
kind: anchor(id),
};
let noop = || diff("abcd", "abcd");
let mut rt = from_markdown("abcd").unwrap();
rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "x")]))
.unwrap();
assert_eq!(
rt.apply_field_change(&mark_bundle(noop(), vec![add(2, 4, "x")])),
Err(ApplyError::AnchorIdCollision { id: "x".into() })
);
let mut rt = from_markdown("abcd").unwrap();
assert_eq!(
rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "")])),
Err(ApplyError::EmptyAnchorId)
);
let mut rt = from_markdown("abcd").unwrap();
rt.apply_field_change(&mark_bundle(noop(), vec![add(0, 2, "x")]))
.unwrap();
rt.apply_field_change(&mark_bundle(
noop(),
vec![MarkOp::RemoveAnchor { id: "x".into() }, add(2, 4, "x")],
))
.unwrap();
let anchors: Vec<_> = rt
.marks
.iter()
.filter(|m| matches!(m.kind, MarkKind::Anchor { .. }))
.collect();
assert_eq!(anchors.len(), 1);
assert_eq!((anchors[0].start, anchors[0].end), (2, 4));
}
fn tag_line(level: u8, continues: bool) -> Line {
Line {
kind: LineKind::Heading { level },
containers: Vec::new(),
continues,
}
}
fn sync_lines(old_chars: &[char], old_lines: Vec<Line>, delta: &Delta) -> Vec<Line> {
sync_for_delta(old_chars, old_lines, Vec::new(), delta).0
}
fn tags(lines: &[Line]) -> Vec<(u8, bool)> {
lines
.iter()
.map(|l| match l.kind {
LineKind::Heading { level } => (level, l.continues),
LineKind::Para => (0, l.continues),
_ => (255, l.continues),
})
.collect()
}
#[test]
fn sync_lines_insert_newline_clones_split_line_and_clears_continues() {
let old_chars: Vec<char> = "a\nbc".chars().collect();
let l1 = Line {
kind: LineKind::Heading { level: 5 },
containers: vec![Container::Quote { instance: 0 }],
continues: true,
};
let lines = vec![tag_line(1, false), l1.clone()];
let d = Delta {
ops: vec![Op::Retain(3), Op::Insert("\n".into()), Op::Retain(1)],
};
let out = sync_lines(&old_chars, lines, &d);
assert_eq!(out.len(), 3);
assert_eq!(out[1], l1, "first half is the untouched original line");
assert_eq!(out[2].kind, LineKind::Heading { level: 5 });
assert_eq!(out[2].containers, vec![Container::Quote { instance: 0 }]);
assert!(!out[2].continues, "the split clone starts a new block");
}
#[test]
fn sync_lines_delete_newline_drops_following_line() {
let old_chars: Vec<char> = "a\nb\nc".chars().collect();
let lines = vec![tag_line(1, false), tag_line(2, false), tag_line(3, false)];
let d = Delta {
ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(3)],
};
let out = sync_lines(&old_chars, lines, &d);
assert_eq!(tags(&out), vec![(1, false), (3, false)]);
}
#[test]
fn sync_walks_lines_and_islands_off_one_cursor() {
let old_chars: Vec<char> = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}").chars().collect();
let d = Delta {
ops: vec![Op::Delete(2), Op::Retain(1)],
};
let (lines, islands) = sync_for_delta(
&old_chars,
vec![tag_line(1, false), tag_line(2, false)],
vec![island("first"), island("second")],
&d,
);
assert_eq!(tags(&lines), vec![(1, false)]);
assert_eq!(islands.iter().map(|i| &i.id).collect::<Vec<_>>(), ["second"]);
}
#[test]
fn sync_lines_delete_trailing_newline_without_following_line_is_guarded() {
let old_chars: Vec<char> = "a\n".chars().collect();
let lines = vec![tag_line(1, false)];
let d = Delta {
ops: vec![Op::Retain(1), Op::Delete(1)],
};
let out = sync_lines(&old_chars, lines, &d);
assert_eq!(tags(&out), vec![(1, false)]);
}
#[test]
fn sync_lines_stops_at_end_of_old_chars() {
let old_chars: Vec<char> = "a\nb".chars().collect();
let lines = vec![tag_line(1, false), tag_line(2, false)];
let d = Delta {
ops: vec![Op::Retain(99)],
};
assert_eq!(sync_lines(&old_chars, lines.clone(), &d), lines);
}
#[test]
fn split_line_rebases_mark_across_the_split_point() {
let mut rt = from_markdown("abcd").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 1,
end: 3,
kind: MarkKind::Strong,
}])
.unwrap();
rt.apply_line_ops(&[LineOp::Split { at: 2 }]).unwrap();
assert_eq!(rt.text, "ab\ncd");
let strong: Vec<_> = rt
.marks
.iter()
.filter(|m| matches!(m.kind, MarkKind::Strong))
.map(|m| (m.start, m.end))
.collect();
assert_eq!(strong, vec![(1, 4)]);
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn join_line_rebases_marks_to_final_text_coordinates() {
let mut rt = from_markdown("ab").unwrap().into_content();
rt.apply_text_delta(&diff("ab", "ab\ncd")).unwrap();
rt.marks.push(Mark {
start: 2,
end: 4,
kind: MarkKind::Strong,
});
let mut rt = rt.into_normalized();
rt.apply_line_ops(&[LineOp::Join { line: 0 }]).unwrap();
assert_eq!(rt.text, "abcd");
let strong: Vec<_> = rt
.marks
.iter()
.filter(|m| matches!(m.kind, MarkKind::Strong))
.map(|m| (m.start, m.end))
.collect();
assert_eq!(strong, vec![(2, 3)], "strong lands on 'c', not 'd' or 'cd'");
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn field_change_terminal_normalize_matches_per_stage_normalize() {
let start = from_markdown("hello world").unwrap();
let text_delta = diff("hello world", "hello brave world");
let line_ops = vec![LineOp::Split { at: 5 }]; let mark_ops = vec![MarkOp::Add {
start: 0,
end: 5,
kind: MarkKind::Strong,
}];
let mut bundled = start.clone();
bundled
.apply_field_change(&ChangeBundle {
delta: text_delta.clone(),
line_ops: line_ops.clone(),
mark_ops: mark_ops.clone(),
..Default::default()
})
.unwrap();
let mut staged = start;
staged.apply_text_delta(&text_delta).unwrap();
staged.apply_line_ops(&line_ops).unwrap();
staged.apply_mark_ops(&mark_ops).unwrap();
assert_eq!(bundled, staged, "terminal normalize diverged from per-stage");
assert_eq!(bundled.validate(), Ok(()));
}
#[test]
fn sync_lines_select_all_delete_collapses_to_first_line() {
let text: String = (0..50).map(|i| format!("line{i}\n")).collect();
let old_chars: Vec<char> = text.chars().collect();
let lines: Vec<Line> = (0..=50).map(|i| tag_line((i % 200) as u8, false)).collect();
assert_eq!(lines.len(), old_chars.iter().filter(|&&c| c == '\n').count() + 1);
let d = Delta {
ops: vec![Op::Delete(old_chars.len())],
};
let out = sync_lines(&old_chars, lines, &d);
assert_eq!(tags(&out), vec![(0, false)], "only the first line survives");
}
#[test]
fn sync_lines_insert_newline_past_end_appends_default() {
let old_chars: Vec<char> = "a\n".chars().collect();
let lines = vec![tag_line(1, false)];
let d = Delta {
ops: vec![Op::Retain(2), Op::Insert("\n".into())],
};
let out = sync_lines(&old_chars, lines, &d);
assert_eq!(out.len(), 2);
assert_eq!(tags(&out)[0], (1, false));
assert_eq!(out[1].kind, LineKind::Para);
assert!(out[1].containers.is_empty());
assert!(!out[1].continues);
}
}