alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Typed buffer edit operations.

use crate::{
    text_stream::{TextByteStream, TextStreamError},
    vim::VimCursor,
};
use std::ops::Range;

/// A synchronous mutation against the authoritative text buffer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BufferEdit {
    /// Replace the entire buffer.
    SetAll(String),
    /// Insert text at a byte index.
    Insert {
        /// Byte index where text should be inserted.
        byte_index: usize,
        /// Text to insert.
        text: String,
    },
    /// Replace a byte range with text.
    Replace {
        /// Byte range to replace.
        range: Range<usize>,
        /// Replacement text.
        text: String,
    },
    /// Delete a byte range.
    Delete {
        /// Byte range to delete.
        range: Range<usize>,
    },
}

impl BufferEdit {
    /// Applies this edit to `stream`.
    ///
    /// # Errors
    ///
    /// Returns [`TextStreamError`] when an edit byte boundary is invalid.
    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(),
        })
    }

    /// Applies this edit and clamps `cursor` to the edited buffer.
    ///
    /// # Errors
    ///
    /// Returns [`TextStreamError`] when an edit byte boundary is invalid.
    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)
    }
}

/// Revision metadata returned after a successful buffer edit.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BufferEditReport {
    /// Revision before the edit.
    pub previous_revision: u64,
    /// Revision after the edit.
    pub current_revision: u64,
}

impl BufferEditReport {
    /// Returns whether the edit changed the stream revision.
    #[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("");

        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(), "");
        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);
        }
    }
}