use crate::{
ecs::components::buffer::BufferEntity,
text_stream::{TextByteStream, TextRange, TextRevision, TextStreamError, ValidatedTextRange},
vim::VimCursor,
};
use std::fmt::{Debug, Formatter};
pub const DEFAULT_UNDO_RECORD_LIMIT: usize = 256;
pub const DEFAULT_UNDO_RETAINED_BYTES: usize = 8 * 1024 * 1024;
#[derive(Clone, Eq, PartialEq)]
pub struct BufferEdit {
operation: BufferEditOperation,
}
impl std::fmt::Debug for BufferEdit {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BufferEdit")
.field("shape", &BufferEditShape::from_edit(self))
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum BufferEditOperation {
SetAll(String),
Insert {
byte_index: usize,
text: String,
},
Replace {
range: BufferEditRange,
text: String,
},
Delete {
range: BufferEditRange,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BufferEditRange {
Unchecked(TextRange),
Validated(ValidatedTextRange),
}
impl BufferEditRange {
const fn text_range(self) -> TextRange {
match self {
Self::Unchecked(range) => range,
Self::Validated(range) => TextRange::new(range.start(), range.end()),
}
}
fn validate(self, stream: &TextByteStream) -> Result<ValidatedTextRange, TextStreamError> {
match self {
Self::Unchecked(range) => stream.validate_range(range),
Self::Validated(range) => stream.validate_validated_range(range),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BufferEditKind {
SetAll,
Insert,
Replace,
Delete,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BufferEditShape {
kind: BufferEditKind,
byte_index: Option<usize>,
range: Option<TextRange>,
replacement_byte_len: Option<usize>,
}
impl BufferEditShape {
#[must_use]
pub fn from_edit(edit: &BufferEdit) -> Self {
Self::from(edit)
}
#[must_use]
pub const fn kind(&self) -> BufferEditKind {
self.kind
}
#[must_use]
pub const fn byte_index(&self) -> Option<usize> {
self.byte_index
}
#[must_use]
pub const fn range(&self) -> Option<TextRange> {
self.range
}
#[must_use]
pub const fn replacement_byte_len(&self) -> Option<usize> {
self.replacement_byte_len
}
}
impl From<&BufferEdit> for BufferEditShape {
fn from(edit: &BufferEdit) -> Self {
match &edit.operation {
BufferEditOperation::SetAll(text) => Self {
kind: BufferEditKind::SetAll,
byte_index: None,
range: None,
replacement_byte_len: Some(text.len()),
},
BufferEditOperation::Insert { byte_index, text } => Self {
kind: BufferEditKind::Insert,
byte_index: Some(*byte_index),
range: None,
replacement_byte_len: Some(text.len()),
},
BufferEditOperation::Replace { range, text } => Self {
kind: BufferEditKind::Replace,
byte_index: None,
range: Some(range.text_range()),
replacement_byte_len: Some(text.len()),
},
BufferEditOperation::Delete { range } => Self {
kind: BufferEditKind::Delete,
byte_index: None,
range: Some(range.text_range()),
replacement_byte_len: None,
},
}
}
}
impl BufferEdit {
#[must_use]
pub fn set_all(text: impl Into<String>) -> Self {
Self {
operation: BufferEditOperation::SetAll(text.into()),
}
}
#[must_use]
pub fn insert(byte_index: usize, text: impl Into<String>) -> Self {
Self {
operation: BufferEditOperation::Insert {
byte_index,
text: text.into(),
},
}
}
#[must_use]
pub fn replace_unchecked(range: impl Into<TextRange>, text: impl Into<String>) -> Self {
Self {
operation: BufferEditOperation::Replace {
range: BufferEditRange::Unchecked(range.into()),
text: text.into(),
},
}
}
#[must_use]
pub fn replace_validated(range: ValidatedTextRange, text: impl Into<String>) -> Self {
Self {
operation: BufferEditOperation::Replace {
range: BufferEditRange::Validated(range),
text: text.into(),
},
}
}
#[must_use]
pub fn delete_unchecked(range: impl Into<TextRange>) -> Self {
Self {
operation: BufferEditOperation::Delete {
range: BufferEditRange::Unchecked(range.into()),
},
}
}
#[must_use]
pub const fn delete_validated(range: ValidatedTextRange) -> Self {
Self {
operation: BufferEditOperation::Delete {
range: BufferEditRange::Validated(range),
},
}
}
pub fn apply(&self, stream: &mut TextByteStream) -> Result<BufferEditReport, BufferEditError> {
let previous_revision = stream.revision();
match &self.operation {
BufferEditOperation::SetAll(text) => stream.replace_all(text.clone()),
BufferEditOperation::Insert { byte_index, text } => {
stream.insert_str(*byte_index, text)?;
}
BufferEditOperation::Replace { range, text } => {
let range = (*range).validate(stream)?;
stream.replace_validated_range(range, text)?;
}
BufferEditOperation::Delete { range } => {
let range = (*range).validate(stream)?;
stream.delete_validated_range(range)?;
}
}
Ok(BufferEditReport {
previous_revision,
current_revision: stream.revision(),
})
}
pub fn apply_with_cursor(
&self,
stream: &mut TextByteStream,
cursor: &mut VimCursor,
) -> Result<BufferEditReport, BufferEditError> {
let report = self.apply(stream)?;
cursor.clamp_to_text(stream.as_str());
Ok(report)
}
const fn retained_text_bytes(&self) -> usize {
match &self.operation {
BufferEditOperation::SetAll(text)
| BufferEditOperation::Insert { text, .. }
| BufferEditOperation::Replace { text, .. } => text.len(),
BufferEditOperation::Delete { .. } => 0,
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct EditTransaction {
forward: BufferEdit,
inverse: BufferEdit,
}
impl Debug for EditTransaction {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("EditTransaction")
.field("shape", &EditTransactionShape::from(self))
.finish()
}
}
impl EditTransaction {
pub fn from_edit(edit: BufferEdit, stream: &TextByteStream) -> Result<Self, BufferEditError> {
match &edit.operation {
BufferEditOperation::SetAll(_) => Ok(Self {
forward: edit,
inverse: BufferEdit::set_all(stream.as_str()),
}),
BufferEditOperation::Insert { byte_index, text } => {
let insertion_point = TextRange::new(*byte_index, *byte_index);
let _validated = stream.validate_range(insertion_point)?;
let inverse_range = *byte_index..byte_index.saturating_add(text.len());
Ok(Self {
forward: edit,
inverse: BufferEdit::delete_unchecked(inverse_range),
})
}
BufferEditOperation::Replace { range, text } => {
let validated = (*range).validate(stream)?;
let removed = stream.as_str()[validated.as_range()].to_owned();
Ok(Self {
forward: BufferEdit::replace_validated(validated, text.clone()),
inverse: BufferEdit::replace_unchecked(
validated.start()..validated.start().saturating_add(text.len()),
removed,
),
})
}
BufferEditOperation::Delete { range } => {
let validated = (*range).validate(stream)?;
let removed = stream.as_str()[validated.as_range()].to_owned();
Ok(Self {
forward: BufferEdit::delete_validated(validated),
inverse: BufferEdit::insert(validated.start(), removed),
})
}
}
}
pub fn apply(&self, stream: &mut TextByteStream) -> Result<BufferEditReport, BufferEditError> {
self.forward.apply(stream)
}
#[must_use]
pub fn into_undo_record(self) -> UndoRecord {
UndoRecord {
undo: self.inverse,
redo: self.forward,
}
}
#[must_use]
pub fn shape(&self) -> EditTransactionShape {
EditTransactionShape::from(self)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EditTransactionShape {
forward: BufferEditShape,
inverse: BufferEditShape,
}
impl EditTransactionShape {
#[must_use]
pub const fn forward(&self) -> &BufferEditShape {
&self.forward
}
#[must_use]
pub const fn inverse(&self) -> &BufferEditShape {
&self.inverse
}
}
impl From<&EditTransaction> for EditTransactionShape {
fn from(transaction: &EditTransaction) -> Self {
Self {
forward: BufferEditShape::from_edit(&transaction.forward),
inverse: BufferEditShape::from_edit(&transaction.inverse),
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct UndoRecord {
undo: BufferEdit,
redo: BufferEdit,
}
impl Debug for UndoRecord {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UndoRecord")
.field("shape", &UndoRecordShape::from(self))
.finish()
}
}
impl UndoRecord {
#[must_use]
pub const fn undo_edit(&self) -> &BufferEdit {
&self.undo
}
#[must_use]
pub const fn redo_edit(&self) -> &BufferEdit {
&self.redo
}
const fn retained_text_bytes(&self) -> usize {
self.undo.retained_text_bytes() + self.redo.retained_text_bytes()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UndoRecordShape {
undo: BufferEditShape,
redo: BufferEditShape,
}
impl From<&UndoRecord> for UndoRecordShape {
fn from(record: &UndoRecord) -> Self {
Self {
undo: BufferEditShape::from_edit(&record.undo),
redo: BufferEditShape::from_edit(&record.redo),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UndoStack {
undo: Vec<UndoRecord>,
redo: Vec<UndoRecord>,
max_records: usize,
max_retained_bytes: usize,
retained_bytes: usize,
}
impl Default for UndoStack {
fn default() -> Self {
Self::bounded(DEFAULT_UNDO_RECORD_LIMIT, DEFAULT_UNDO_RETAINED_BYTES)
}
}
impl UndoStack {
#[must_use]
pub const fn bounded(max_records: usize, max_retained_bytes: usize) -> Self {
Self {
undo: Vec::new(),
redo: Vec::new(),
max_records,
max_retained_bytes,
retained_bytes: 0,
}
}
pub fn record_applied(&mut self, record: UndoRecord) {
self.clear_redo();
if self.max_records == 0 || self.max_retained_bytes == 0 {
self.undo.clear();
self.retained_bytes = 0;
return;
}
let retained = record.retained_text_bytes();
if retained > self.max_retained_bytes {
self.undo.clear();
self.retained_bytes = 0;
return;
}
self.retained_bytes = self.retained_bytes.saturating_add(retained);
self.undo.push(record);
self.enforce_limits();
}
#[must_use]
pub fn pop_undo(&mut self) -> Option<UndoRecord> {
self.pop_from_undo()
}
#[must_use]
pub fn pop_redo(&mut self) -> Option<UndoRecord> {
self.pop_from_redo()
}
pub fn restore_undo(&mut self, record: UndoRecord) {
self.push_undo_preserving_redo(record);
}
pub fn restore_redo(&mut self, record: UndoRecord) {
self.push_redo(record);
}
pub fn record_undone(&mut self, record: UndoRecord) {
self.push_redo(record);
}
pub fn record_redone(&mut self, record: UndoRecord) {
self.push_undo_preserving_redo(record);
}
#[must_use]
pub const fn undo_len(&self) -> usize {
self.undo.len()
}
#[must_use]
pub const fn redo_len(&self) -> usize {
self.redo.len()
}
#[must_use]
pub const fn retained_bytes(&self) -> usize {
self.retained_bytes
}
fn clear_redo(&mut self) {
let cleared = self
.redo
.iter()
.map(UndoRecord::retained_text_bytes)
.sum::<usize>();
self.redo.clear();
self.retained_bytes = self.retained_bytes.saturating_sub(cleared);
}
fn enforce_limits(&mut self) {
while self.undo.len() > self.max_records || self.retained_bytes > self.max_retained_bytes {
let Some(record) = (!self.undo.is_empty()).then(|| self.undo.remove(0)) else {
break;
};
self.retained_bytes = self
.retained_bytes
.saturating_sub(record.retained_text_bytes());
}
}
fn pop_from_undo(&mut self) -> Option<UndoRecord> {
let record = self.undo.pop()?;
self.retained_bytes = self
.retained_bytes
.saturating_sub(record.retained_text_bytes());
Some(record)
}
fn pop_from_redo(&mut self) -> Option<UndoRecord> {
let record = self.redo.pop()?;
self.retained_bytes = self
.retained_bytes
.saturating_sub(record.retained_text_bytes());
Some(record)
}
fn push_undo_preserving_redo(&mut self, record: UndoRecord) {
self.retained_bytes = self
.retained_bytes
.saturating_add(record.retained_text_bytes());
self.undo.push(record);
self.enforce_limits();
}
fn push_redo(&mut self, record: UndoRecord) {
self.retained_bytes = self
.retained_bytes
.saturating_add(record.retained_text_bytes());
self.redo.push(record);
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum BufferEditError {
#[error("buffer edit violates text invariants: {source}")]
InvalidTextBoundary {
#[from]
source: TextStreamError,
},
#[error("buffer edit target {target:?} does not exist")]
MissingTarget {
target: BufferEntity,
},
}
impl BufferEditError {
#[must_use]
pub const fn text_error(&self) -> Option<&TextStreamError> {
match self {
Self::InvalidTextBoundary { source } => Some(source),
Self::MissingTarget { .. } => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BufferEditReport {
pub previous_revision: TextRevision,
pub current_revision: TextRevision,
}
impl BufferEditReport {
#[must_use]
pub fn changed(self) -> bool {
self.previous_revision != self.current_revision
}
}
#[cfg(test)]
mod tests {
use super::{BufferEdit, BufferEditReport, EditTransaction, UndoStack};
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(4, String::from("!"))
.apply(&mut stream)
.expect("insert should succeed");
assert_eq!(stream.as_str(), "alma!");
assert_eq!(
report,
BufferEditReport {
previous_revision: 0.into(),
current_revision: 1.into()
}
);
assert!(report.changed());
}
#[test]
fn rejects_non_utf8_boundary() {
let mut stream = TextByteStream::new("aλ");
let error = BufferEdit::delete_unchecked(1..2)
.apply(&mut stream)
.expect_err("lambda byte split should fail");
assert_eq!(
error.to_string(),
"buffer edit violates text invariants: byte index 2 is not a UTF-8 character boundary"
);
assert_eq!(
error.text_error(),
Some(&crate::text_stream::TextStreamError::NotCharBoundary { index: 2 })
);
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_unchecked(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);
}
#[test]
fn undo_debug_output_redacts_retained_text() {
let secret = "secret retained text";
let stream = TextByteStream::new(secret);
let transaction =
EditTransaction::from_edit(BufferEdit::delete_unchecked(0..secret.len()), &stream)
.expect("delete transaction should validate");
let record = transaction.into_undo_record();
let mut stack = UndoStack::default();
stack.record_applied(record);
let debug = format!("{stack:?}");
assert!(debug.contains("UndoStack"));
assert!(debug.contains("byte_len"));
assert!(!debug.contains(secret));
assert!(!debug.contains("retained text"));
}
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_unchecked(range, 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_unchecked(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);
}
}
}