use crate::delta::{Assoc, Delta, Op};
use crate::model::{Container, Island, Line, LineKind, Mark, MarkKind, Content, Usv, ISLAND_SLOT};
use crate::normalize::is_bidi_char;
use crate::usv::char_to_byte;
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 },
}
use crate::serial::{
container_from_value, container_to_value, line_kind_from_value, line_kind_to_value,
mark_from_value, mark_to_value, ParseError,
};
use serde_json::{Map, Value};
pub fn mark_op_to_value(op: &MarkOp) -> Value {
let mut m = Map::new();
match op {
MarkOp::Add { start, end, kind } => {
m.insert("op".into(), "add".into());
merge_mark(&mut m, *start, *end, kind);
}
MarkOp::Remove { start, end, kind } => {
m.insert("op".into(), "remove".into());
merge_mark(&mut m, *start, *end, kind);
}
MarkOp::RemoveAnchor { id } => {
m.insert("op".into(), "removeAnchor".into());
m.insert("id".into(), Value::String(id.clone()));
}
}
Value::Object(m)
}
fn merge_mark(m: &mut Map<String, Value>, start: Usv, end: Usv, kind: &MarkKind) {
let mark = Mark {
start,
end,
kind: kind.clone(),
};
if let Value::Object(fields) = mark_to_value(&mark) {
m.extend(fields);
}
}
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") => {
let mark = mark_from_value(v)?;
Ok(MarkOp::Add {
start: mark.start,
end: mark.end,
kind: mark.kind,
})
}
Some("remove") => {
let mark = mark_from_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_to_value(op: &LineOp) -> Value {
let mut m = Map::new();
match op {
LineOp::Split { at } => {
m.insert("op".into(), "split".into());
m.insert("at".into(), Value::from(*at));
}
LineOp::Join { line } => {
m.insert("op".into(), "join".into());
m.insert("line".into(), Value::from(*line));
}
LineOp::SetKind { line, kind } => {
m.insert("op".into(), "setKind".into());
m.insert("line".into(), Value::from(*line));
if let Value::Object(fields) = line_kind_to_value(kind) {
m.extend(fields);
}
}
LineOp::SetContainers { line, containers } => {
m.insert("op".into(), "setContainers".into());
m.insert("line".into(), Value::from(*line));
m.insert(
"containers".into(),
Value::Array(containers.iter().map(container_to_value).collect()),
);
}
LineOp::SetContinues { line, continues } => {
m.insert("op".into(), "setContinues".into());
m.insert("line".into(), Value::from(*line));
m.insert("continues".into(), Value::Bool(*continues));
}
}
Value::Object(m)
}
pub fn line_op_from_value(v: &Value) -> Result<LineOp, ParseError> {
let o = v.as_object().ok_or(ParseError::Shape("line op"))?;
let line = || {
o.get("line")
.and_then(Value::as_u64)
.map(|n| n as usize)
.ok_or(ParseError::Shape("line op line"))
};
match o.get("op").and_then(Value::as_str) {
Some("split") => Ok(LineOp::Split {
at: o
.get("at")
.and_then(Value::as_u64)
.map(|n| n as usize)
.ok_or(ParseError::Shape("split at"))?,
}),
Some("join") => Ok(LineOp::Join { line: line()? }),
Some("setKind") => Ok(LineOp::SetKind {
line: line()?,
kind: line_kind_from_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_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")),
}
}
#[allow(clippy::type_complexity)]
pub fn change_bundle_from_value(
v: &Value,
) -> Result<(Delta, Vec<LineOp>, Vec<MarkOp>), String> {
let obj = v
.as_object()
.ok_or("bundle must be an object { delta?, lineOps?, markOps? }")?;
let get = |snake: &str, camel: &str| obj.get(snake).or_else(|| obj.get(camel));
let delta = match get("delta", "delta") {
Some(Value::Null) | None => Delta { ops: Vec::new() },
Some(d) => serde_json::from_value(d.clone()).map_err(|e| format!("invalid delta: {e}"))?,
};
let line_ops = op_array(get("line_ops", "lineOps"), line_op_from_value, "lineOps")?;
let mark_ops = op_array(get("mark_ops", "markOps"), mark_op_from_value, "markOps")?;
Ok((delta, line_ops, mark_ops))
}
fn op_array<T>(
value: Option<&Value>,
convert: impl Fn(&Value) -> Result<T, ParseError>,
what: &str,
) -> Result<Vec<T>, String> {
let Some(value) = value else {
return Ok(Vec::new());
};
if value.is_null() {
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,
},
FirstLineContinues,
DeltaBaseMismatch {
expected: usize,
actual: usize,
},
IslandSlotInInsert,
}
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 old_lines = self.lines.clone();
let new_text = delta
.try_apply(&self.text)
.map_err(|e| ApplyError::DeltaBaseMismatch {
expected: e.expected,
actual: e.actual,
})?;
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;
self.lines = sync_lines_for_delta(&old_chars, old_lines, delta);
let old_islands = std::mem::take(&mut self.islands);
self.islands = sync_islands_for_delta(&old_chars, old_islands, delta);
if self.lines.len() != self.segment_count() {
return Err(ApplyError::LineCountMismatch {
lines: self.lines.len(),
segments: self.segment_count(),
});
}
Ok(())
}
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,
});
}
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_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 } => {
let line = self.line_mut(*line)?;
line.kind = kind.clone();
}
LineOp::SetContainers { line, containers } => {
let line = self.line_mut(*line)?;
line.containers = containers.clone();
}
LineOp::SetContinues { line, continues } => {
if *line == 0 && *continues {
return Err(ApplyError::FirstLineContinues);
}
let l = self.line_mut(*line)?;
l.continues = *continues;
}
}
}
Ok(())
}
pub fn apply_field_change(
&mut self,
text_delta: &Delta,
line_ops: &[LineOp],
mark_ops: &[MarkOp],
) -> Result<(), ApplyError> {
if line_ops.is_empty() && mark_ops.is_empty() {
return self.apply_text_delta(text_delta);
}
let mut scratch = self.clone();
scratch.apply_text_delta_inner(text_delta)?;
scratch.apply_line_ops_inner(line_ops)?;
scratch.apply_mark_ops_inner(mark_ops)?;
scratch.normalize();
*self = scratch;
Ok(())
}
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(default_para_line);
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 default_para_line() -> Line {
Line {
kind: LineKind::Para,
containers: Vec::new(),
continues: false,
}
}
fn ranges_overlap(a0: Usv, a1: Usv, b0: Usv, b1: Usv) -> bool {
a0 < b1 && b0 < a1
}
fn insert_forbidden(c: char) -> bool {
c == '\r' || is_bidi_char(c)
}
fn sanitize_inserts(delta: &Delta) -> Cow<'_, Delta> {
let needs_cleaning = delta
.ops
.iter()
.any(|op| matches!(op, Op::Insert(s) if s.chars().any(insert_forbidden)));
if !needs_cleaning {
return Cow::Borrowed(delta);
}
let ops = delta
.ops
.iter()
.map(|op| match op {
Op::Insert(s) => Op::Insert(s.chars().filter(|c| !insert_forbidden(*c)).collect()),
other => other.clone(),
})
.collect();
Cow::Owned(Delta { ops })
}
fn sync_lines_for_delta(old_chars: &[char], old_lines: Vec<Line>, delta: &Delta) -> Vec<Line> {
let cap = old_lines.len();
let mut rest = old_lines.into_iter();
let mut out: Vec<Line> = Vec::with_capacity(cap);
let mut cur: Option<Line> = rest.next();
let mut old = 0usize;
for op in &delta.ops {
match op {
Op::Retain(n) => {
for _ in 0..*n {
if old >= old_chars.len() {
break;
}
if old_chars[old] == '\n' {
out.extend(cur.take());
cur = rest.next();
}
old += 1;
}
}
Op::Delete(n) => {
for _ in 0..*n {
if old >= old_chars.len() {
break;
}
if old_chars[old] == '\n' {
rest.next();
}
old += 1;
}
}
Op::Insert(s) => {
for c in s.chars() {
if c == '\n' {
let mut new_line = match cur.take() {
Some(line) => {
let clone = line.clone();
out.push(line);
clone
}
None => default_para_line(),
};
new_line.continues = false;
cur = Some(new_line);
}
}
}
}
}
out.extend(cur);
out.extend(rest);
out
}
fn sync_islands_for_delta(
old_chars: &[char],
old_islands: Vec<Island>,
delta: &Delta,
) -> Vec<Island> {
let mut keep = vec![true; old_islands.len()];
let mut old = 0usize;
let mut slot_idx = 0usize;
for op in &delta.ops {
match op {
Op::Retain(n) => {
for _ in 0..*n {
if old >= old_chars.len() {
break;
}
if old_chars[old] == ISLAND_SLOT {
slot_idx += 1;
}
old += 1;
}
}
Op::Delete(n) => {
for _ in 0..*n {
if old >= old_chars.len() {
break;
}
if old_chars[old] == ISLAND_SLOT {
if let Some(k) = keep.get_mut(slot_idx) {
*k = false;
}
slot_idx += 1;
}
old += 1;
}
}
Op::Insert(_) => {}
}
}
old_islands
.into_iter()
.zip(keep)
.filter_map(|(island, keep)| keep.then_some(island))
.collect()
}
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,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delta::diff;
use crate::import::from_markdown;
#[test]
fn mark_op_wire_round_trips_each_variant() {
let ops = vec![
MarkOp::Add {
start: 0,
end: 3,
kind: MarkKind::Strong,
},
MarkOp::Add {
start: 1,
end: 2,
kind: MarkKind::Link {
url: "https://x".into(),
},
},
MarkOp::Remove {
start: 4,
end: 6,
kind: MarkKind::Anchor { id: "c1".into() },
},
MarkOp::RemoveAnchor { id: "c2".into() },
];
for op in ops {
let v = mark_op_to_value(&op);
assert_eq!(mark_op_from_value(&v).unwrap(), op, "round-trip: {v}");
}
}
#[test]
fn line_op_wire_round_trips_each_variant() {
let ops = vec![
LineOp::Split { at: 5 },
LineOp::Join { line: 1 },
LineOp::SetKind {
line: 0,
kind: LineKind::Heading { level: 2 },
},
LineOp::SetContainers {
line: 2,
containers: vec![Container::Quote],
},
LineOp::SetContinues {
line: 1,
continues: true,
},
LineOp::SetContinues {
line: 3,
continues: false,
},
];
for op in ops {
let v = line_op_to_value(&op);
assert_eq!(line_op_from_value(&v).unwrap(), op, "round-trip: {v}");
}
}
#[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();
rt.marks.push(Mark {
start: 1,
end: 4,
kind: MarkKind::Strong,
});
rt.normalize();
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");
}
#[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_mark_ops_add_and_remove() {
let mut rt = from_markdown("abcd").unwrap();
rt.apply_mark_ops(&[MarkOp::Add {
start: 0,
end: 2,
kind: MarkKind::Emph,
}])
.unwrap();
assert!(rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
rt.apply_mark_ops(&[MarkOp::Remove {
start: 0,
end: 4,
kind: MarkKind::Emph,
}])
.unwrap();
assert!(!rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Emph)));
}
#[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 mut rt = from_markdown("abcdef").unwrap();
rt.marks.push(Mark {
start: 0,
end: 6,
kind: MarkKind::Unknown {
tag: "x".into(),
attrs: serde_json::json!({}),
},
});
rt.normalize();
rt.apply_mark_ops(&[MarkOp::Remove {
start: 2,
end: 4,
kind: MarkKind::Unknown {
tag: "x".into(),
attrs: serde_json::json!({}),
},
}])
.unwrap();
assert!(!rt
.marks
.iter()
.any(|m| matches!(m.kind, MarkKind::Unknown { .. })));
}
#[test]
fn apply_text_delta_splits_lines_on_newline_insert() {
let mut rt = from_markdown("one two").unwrap();
let d = diff("one two", "one\ntwo");
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.lines.len(), 2);
assert_eq!(rt.segment_count(), 2);
assert_eq!(rt.validate(), Ok(()));
}
#[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_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_rejects_first_line() {
let mut rt = from_markdown("one two").unwrap();
rt.apply_text_delta(&diff("one two", "one\ntwo")).unwrap();
let before = rt.clone();
assert_eq!(
rt.apply_line_ops(&[LineOp::SetContinues {
line: 0,
continues: true,
}]),
Err(ApplyError::FirstLineContinues)
);
assert_eq!(rt, before, "rejected op leaves the content untouched");
rt.apply_line_ops(&[LineOp::SetContinues {
line: 0,
continues: false,
}])
.unwrap();
assert_eq!(rt.validate(), Ok(()));
}
fn island(id: &str) -> Island {
Island {
id: id.into(),
island_type: "image".into(),
props: serde_json::json!({}),
loss: crate::model::Loss::Lossless,
}
}
fn content_with_island() -> Content {
let mut rt = Content::empty();
rt.text = format!("a{ISLAND_SLOT}b");
rt.lines = vec![Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
}];
rt.islands = vec![island("i1")];
assert_eq!(rt.validate(), Ok(()));
rt
}
#[test]
fn delete_slot_cascades_island_removal() {
let mut rt = content_with_island();
let d = Delta {
ops: vec![Op::Retain(1), Op::Delete(1), Op::Retain(1)],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, "ab");
assert!(rt.islands.is_empty(), "island cascaded away with its slot");
assert_eq!(rt.validate(), Ok(()));
}
#[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_raw_slot_is_rejected() {
let mut rt = from_markdown("ab").unwrap();
let d = Delta {
ops: vec![
Op::Retain(1),
Op::Insert(ISLAND_SLOT.to_string()),
Op::Retain(1),
],
};
assert_eq!(rt.apply_text_delta(&d), Err(ApplyError::IslandSlotInInsert));
assert_eq!(rt.text, "ab");
assert!(rt.islands.is_empty());
assert_eq!(rt.validate(), Ok(()));
}
#[test]
fn insert_carriage_return_is_stripped() {
let mut rt = from_markdown("ab").unwrap();
let d = Delta {
ops: vec![Op::Retain(1), Op::Insert("\r".into()), Op::Retain(1)],
};
rt.apply_text_delta(&d).unwrap();
assert_eq!(rt.text, "ab");
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_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(_)));
}
#[test]
fn apply_field_change_bundle_order() {
let mut rt = from_markdown("abc").unwrap();
let d = diff("abc", "abXc");
rt.apply_field_change(
&d,
&[],
&[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(
&d,
&[],
&[
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");
}
fn tag_line(level: u8, continues: bool) -> Line {
Line {
kind: LineKind::Heading { level },
containers: Vec::new(),
continues,
}
}
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_retain_only_is_identity() {
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(5)],
};
assert_eq!(sync_lines_for_delta(&old_chars, lines.clone(), &d), lines);
}
#[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],
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_for_delta(&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]);
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_for_delta(&old_chars, lines, &d);
assert_eq!(tags(&out), vec![(1, false), (3, false)]);
}
#[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_for_delta(&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_for_delta(&old_chars, lines.clone(), &d), lines);
}
#[test]
fn sync_lines_insert_two_newlines_adds_two_clones() {
let old_chars: Vec<char> = "abc".chars().collect();
let src = Line {
kind: LineKind::Heading { level: 7 },
containers: vec![Container::Quote],
continues: false,
};
let d = Delta {
ops: vec![Op::Retain(1), Op::Insert("\n\n".into()), Op::Retain(2)],
};
let out = sync_lines_for_delta(&old_chars, vec![src], &d);
assert_eq!(out.len(), 3);
for l in &out {
assert_eq!(l.kind, LineKind::Heading { level: 7 });
assert_eq!(l.containers, vec![Container::Quote]);
assert!(!l.continues);
}
}
#[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();
rt.apply_text_delta(&diff("ab", "ab\ncd")).unwrap();
rt.marks.push(Mark {
start: 2,
end: 4,
kind: MarkKind::Strong,
});
rt.normalize();
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(&text_delta, &line_ops, &mark_ops)
.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_for_delta(&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_for_delta(&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);
}
}