use crate::{
text_stream::{TextByteStream, TextStreamError},
vim::VimCursor,
};
use std::ops::Range;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BufferEdit {
SetAll(String),
Insert {
byte_index: usize,
text: String,
},
Replace {
range: Range<usize>,
text: String,
},
Delete {
range: Range<usize>,
},
}
impl BufferEdit {
pub fn apply(self, stream: &mut TextByteStream) -> Result<BufferEditReport, TextStreamError> {
let previous_revision = stream.revision();
match self {
Self::SetAll(text) => stream.replace_all(text),
Self::Insert { byte_index, text } => stream.insert_str(byte_index, &text)?,
Self::Replace { range, text } => stream.replace_range(range, &text)?,
Self::Delete { range } => stream.delete_range(range)?,
}
Ok(BufferEditReport {
previous_revision,
current_revision: stream.revision(),
})
}
pub fn apply_with_cursor(
self,
stream: &mut TextByteStream,
cursor: &mut VimCursor,
) -> Result<BufferEditReport, TextStreamError> {
let report = self.apply(stream)?;
cursor.clamp_to_text(stream.as_str());
Ok(report)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BufferEditReport {
pub previous_revision: u64,
pub current_revision: u64,
}
impl BufferEditReport {
#[must_use]
pub const fn changed(self) -> bool {
self.previous_revision != self.current_revision
}
}
#[cfg(test)]
mod tests {
use super::{BufferEdit, BufferEditReport};
use crate::{text_stream::TextByteStream, vim::VimCursor};
use proptest::prelude::*;
#[test]
fn insert_bumps_revision() {
let mut stream = TextByteStream::new("alma");
let report = BufferEdit::Insert {
byte_index: 4,
text: String::from("!"),
}
.apply(&mut stream)
.expect("insert should succeed");
assert_eq!(stream.as_str(), "alma!");
assert_eq!(
report,
BufferEditReport {
previous_revision: 0,
current_revision: 1
}
);
assert!(report.changed());
}
#[test]
fn rejects_non_utf8_boundary() {
let mut stream = TextByteStream::new("aλ");
let error = BufferEdit::Delete { range: 1..2 }
.apply(&mut stream)
.expect_err("lambda byte split should fail");
assert_eq!(
error.to_string(),
"byte index 2 is not a UTF-8 character boundary"
);
assert_eq!(stream.as_str(), "aλ");
assert_eq!(stream.revision(), 0);
}
#[test]
fn clamps_cursor_after_delete() {
let mut stream = TextByteStream::new("abc");
let mut cursor = VimCursor::new();
cursor.set_byte_index(stream.as_str(), 2);
let _report = BufferEdit::Delete { range: 1..3 }
.apply_with_cursor(&mut stream, &mut cursor)
.expect("delete should succeed");
assert_eq!(stream.as_str(), "a");
assert_eq!(cursor.byte_index(), 0);
}
proptest! {
#[test]
fn valid_buffer_edits_match_string_model(
prefix in any::<String>(),
removed in any::<String>(),
suffix in any::<String>(),
replacement in any::<String>(),
) {
let original = format!("{prefix}{removed}{suffix}");
let expected = format!("{prefix}{replacement}{suffix}");
let range = prefix.len()..prefix.len() + removed.len();
let mut stream = TextByteStream::new(original);
let report = BufferEdit::Replace {
range,
text: replacement,
}
.apply(&mut stream)
.expect("generated range should be UTF-8 aligned");
prop_assert_eq!(stream.as_str(), expected.as_str());
prop_assert_eq!(report.previous_revision, 0);
prop_assert_eq!(report.current_revision, 1);
}
#[test]
fn invalid_buffer_edit_ranges_leave_stream_unchanged(
prefix in any::<String>(),
character in any::<char>().prop_filter(
"character must use multiple UTF-8 bytes",
|character| 1 < character.len_utf8(),
),
) {
let original = format!("{prefix}{character}");
let split_index = prefix.len() + 1;
let mut stream = TextByteStream::new(original.clone());
let result = BufferEdit::Delete {
range: split_index..split_index,
}.apply(&mut stream);
prop_assert!(result.is_err());
prop_assert_eq!(stream.as_str(), original.as_str());
prop_assert_eq!(stream.revision(), 0);
}
}
}