codemp/src/api/change.rs

245 lines
6.7 KiB
Rust
Raw Normal View History

//! # TextChange
//!
//! an editor-friendly representation of a text change in a buffer
//! to easily interface with codemp from various editors
use crate::woot::{
crdt::{TextEditor, CRDT},
woot::Woot,
WootResult,
};
/// an atomic and orderable operation
///
/// this under the hood thinly wraps our CRDT operation
#[derive(Debug, Clone)]
pub struct Op(pub(crate) woot::crdt::Op);
/// an editor-friendly representation of a text change in a buffer
///
/// this represent a range in the previous state of the string and a new content which should be
/// replaced to it, allowing to represent any combination of deletions, insertions or replacements
///
/// bulk and widespread operations will result in a TextChange effectively sending the whole new
/// buffer, but small changes are efficient and easy to create or apply
///
/// ### examples
/// to insert 'a' after 4th character we should send a
/// `TextChange { span: 4..4, content: "a".into() }`
///
/// to delete a the fourth character we should send a
/// `TextChange { span: 3..4, content: "".into() }`
///
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "js", napi_derive::napi(object))]
#[cfg_attr(feature = "python", pyo3::pyclass)]
#[cfg_attr(feature = "python", pyo3(get_all))]
pub struct TextChange {
/// range start of text change, as char indexes in buffer previous state
pub start: u32,
/// range end of text change, as char indexes in buffer previous state
pub end: u32,
/// new content of text inside span
pub content: String,
}
impl TextChange {
/// create a new TextChange from the difference of given strings
pub fn from_diff(before: &str, after: &str) -> TextChange {
let diff = similar::TextDiff::from_chars(before, after);
let mut start = 0;
let mut end = 0;
let mut from_beginning = true;
for op in diff.ops() {
match op {
similar::DiffOp::Equal { len, .. } => {
if from_beginning {
start += len
} else {
end += len
}
}
_ => {
end = 0;
from_beginning = false;
}
}
}
let end_before = before.len() - end;
let end_after = after.len() - end;
TextChange {
start: start as u32,
end: end_before as u32,
content: after[start..end_after].to_string(),
}
}
pub fn span(&self) -> std::ops::Range<usize> {
self.start as usize..self.end as usize
}
/// consume the [TextChange], transforming it into a Vec of [Op]
pub fn transform(&self, woot: &Woot) -> WootResult<Vec<Op>> {
let mut out = Vec::new();
if self.is_empty() {
return Ok(out);
} // no-op
let view = woot.view();
let Some(span) = view.get(self.span()) else {
return Err(crate::woot::WootError::OutOfBounds);
};
let diff = similar::TextDiff::from_chars(span, &self.content);
for (i, diff) in diff.iter_all_changes().enumerate() {
match diff.tag() {
similar::ChangeTag::Equal => {}
similar::ChangeTag::Delete => match woot.delete_one(self.span().start + i) {
Err(e) => tracing::error!("could not create deletion: {}", e),
Ok(op) => out.push(Op(op)),
},
similar::ChangeTag::Insert => {
match woot.insert(self.span().start + i, diff.value()) {
Ok(ops) => {
for op in ops {
out.push(Op(op))
}
}
Err(e) => tracing::error!("could not create insertion: {}", e),
}
}
}
}
Ok(out)
}
/// returns true if this TextChange deletes existing text
pub fn is_deletion(&self) -> bool {
!self.span().is_empty()
}
/// returns true if this TextChange adds new text
pub fn is_addition(&self) -> bool {
!self.content.is_empty()
}
/// returns true if this TextChange is effectively as no-op
pub fn is_empty(&self) -> bool {
!self.is_deletion() && !self.is_addition()
}
/// applies this text change to given text, returning a new string
pub fn apply(&self, txt: &str) -> String {
let pre_index = std::cmp::min(self.span().start, txt.len());
2023-11-30 03:02:13 +01:00
let pre = txt.get(..pre_index).unwrap_or("").to_string();
let post = txt.get(self.span().end..).unwrap_or("").to_string();
format!("{}{}{}", pre, self.content, post)
}
/// convert from byte index to row and column
/// txt must be the whole content of the buffer, in order to count lines
pub fn index_to_rowcol(txt: &str, index: usize) -> codemp_proto::cursor::RowCol {
2023-11-30 03:02:13 +01:00
// FIXME might panic, use .get()
let row = txt[..index].matches('\n').count() as i32;
let col = txt[..index].split('\n').last().unwrap_or("").len() as i32;
codemp_proto::cursor::RowCol { row, col }
}
}
#[cfg(test)]
mod tests {
#[test]
fn textchange_diff_works_for_deletions() {
let change = super::TextChange::from_diff(
"sphinx of black quartz, judge my vow",
"sphinx of quartz, judge my vow",
);
assert_eq!(change.span(), 10..16);
assert_eq!(change.content, "");
}
#[test]
fn textchange_diff_works_for_insertions() {
let change = super::TextChange::from_diff(
"sphinx of quartz, judge my vow",
"sphinx of black quartz, judge my vow",
);
assert_eq!(change.span(), 10..10);
assert_eq!(change.content, "black ");
}
#[test]
fn textchange_diff_works_for_changes() {
let change = super::TextChange::from_diff(
"sphinx of black quartz, judge my vow",
"sphinx who watches the desert, judge my vow",
);
assert_eq!(change.span(), 7..22);
assert_eq!(change.content, "who watches the desert");
}
#[test]
fn textchange_apply_works_for_insertions() {
let change = super::TextChange {
start: 5,
end: 5,
content: " cruel".to_string(),
};
let result = change.apply("hello world!");
assert_eq!(result, "hello cruel world!");
}
#[test]
fn textchange_apply_works_for_deletions() {
let change = super::TextChange {
start: 5,
end: 11,
content: "".to_string(),
};
let result = change.apply("hello cruel world!");
assert_eq!(result, "hello world!");
}
#[test]
fn textchange_apply_works_for_replacements() {
let change = super::TextChange {
start: 5,
end: 11,
content: " not very pleasant".to_string(),
};
let result = change.apply("hello cruel world!");
assert_eq!(result, "hello not very pleasant world!");
}
2023-11-30 03:01:59 +01:00
#[test]
fn textchange_apply_never_panics() {
let change = super::TextChange {
start: 100,
end: 110,
content: "a very long string \n which totally matters".to_string(),
};
2023-11-30 03:01:59 +01:00
let result = change.apply("a short text");
assert_eq!(
result,
"a short texta very long string \n which totally matters"
);
2023-11-30 03:01:59 +01:00
}
#[test]
fn empty_diff_produces_empty_textchange() {
let change = super::TextChange::from_diff("same \n\n text", "same \n\n text");
assert!(change.is_empty());
}
2023-11-30 03:01:59 +01:00
#[test]
fn empty_textchange_doesnt_alter_buffer() {
let change = super::TextChange {
start: 42,
end: 42,
content: "".to_string(),
};
2023-11-30 03:01:59 +01:00
let result = change.apply("some important text");
assert_eq!(result, "some important text");
}
}