alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Pure Vim paste planning.
//!
//! Paste planning consumes register text and a cursor snapshot. It does not
//! mutate buffers or registers. The returned plan is still only intent; the
//! buffer owner must validate the insertion index before changing text.

use super::{Count, RegisterKind, RegisterText, motion};

/// Normal-mode paste side.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PastePlacement {
    /// `p`: paste after the cursor cell, or below the current line.
    After,
    /// `P`: paste before the cursor cell, or above the current line.
    Before,
}

/// Planned paste edit and post-paste cursor cell.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PastePlan {
    /// Byte index where register text should be inserted.
    byte_index: usize,
    /// Text to insert. This is user text and must stay out of diagnostics.
    text: String,
    /// Normal-mode cursor cell after the paste is applied.
    cursor_byte_index: usize,
}

impl PastePlan {
    /// Builds a paste plan from explicit fields.
    #[must_use]
    const fn new(byte_index: usize, text: String, cursor_byte_index: usize) -> Self {
        Self {
            byte_index,
            text,
            cursor_byte_index,
        }
    }

    /// Returns the byte index where text should be inserted.
    #[must_use]
    pub const fn byte_index(&self) -> usize {
        self.byte_index
    }

    /// Returns the insertion text.
    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }

    /// Consumes the plan and returns the insertion text.
    #[must_use]
    pub fn into_text(self) -> String {
        self.text
    }

    /// Returns the cursor cell to request after the paste.
    #[must_use]
    pub const fn cursor_byte_index(&self) -> usize {
        self.cursor_byte_index
    }
}

/// Paste planning failure.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum PastePlanError {
    /// The selected register contains no bytes.
    EmptyRegister,
    /// Counted paste would exceed the configured byte budget.
    PayloadTooLarge {
        /// Bytes that would be inserted.
        requested_byte_len: usize,
        /// Maximum accepted bytes for one paste request.
        max_byte_len: usize,
    },
}

/// Builds the concrete paste edit for a register.
///
/// # Errors
///
/// Returns [`PastePlanError`] when the register is empty or when count
/// expansion would exceed `max_byte_len`.
pub fn plan_paste(
    text: &str,
    cursor_byte_index: usize,
    register: &RegisterText,
    count: Count,
    placement: PastePlacement,
    max_byte_len: usize,
) -> Result<PastePlan, PastePlanError> {
    if register.is_empty() {
        return Err(PastePlanError::EmptyRegister);
    }

    match register.kind() {
        RegisterKind::Characterwise => plan_characterwise_paste(
            text,
            cursor_byte_index,
            register,
            count,
            placement,
            max_byte_len,
        ),
        RegisterKind::Linewise => plan_linewise_paste(
            text,
            cursor_byte_index,
            register,
            count,
            placement,
            max_byte_len,
        ),
    }
}

/// Plans a characterwise paste.
fn plan_characterwise_paste(
    text: &str,
    cursor_byte_index: usize,
    register: &RegisterText,
    count: Count,
    placement: PastePlacement,
    max_byte_len: usize,
) -> Result<PastePlan, PastePlanError> {
    let insert_at = match placement {
        PastePlacement::After => next_char_boundary_for_paste(text, cursor_byte_index),
        PastePlacement::Before => motion::clamp_to_boundary(text, cursor_byte_index),
    };
    let inserted = counted_text(register.as_str(), count, max_byte_len)?;
    let cursor = inserted.chars().last().map_or(insert_at, |character| {
        insert_at + inserted.len().saturating_sub(character.len_utf8())
    });

    Ok(PastePlan::new(insert_at, inserted, cursor))
}

/// Plans a linewise paste.
fn plan_linewise_paste(
    text: &str,
    cursor_byte_index: usize,
    register: &RegisterText,
    count: Count,
    placement: PastePlacement,
    max_byte_len: usize,
) -> Result<PastePlan, PastePlanError> {
    let cursor = motion::clamp_to_boundary(text, cursor_byte_index);
    let line_start = line_start(text, cursor);
    let line_end = line_end_including_newline(text, cursor);
    let mut inserted = counted_linewise_text(register.as_str(), count, max_byte_len)?;
    let mut pasted_start_offset = 0;

    let insert_at = match placement {
        PastePlacement::Before => line_start,
        PastePlacement::After if text.is_empty() => 0,
        PastePlacement::After if line_end == text.len() && !text.ends_with('\n') => {
            inserted.insert(0, '\n');
            pasted_start_offset = '\n'.len_utf8();
            text.len()
        }
        PastePlacement::After => line_end,
    };

    if inserted.len() > max_byte_len {
        return Err(PastePlanError::PayloadTooLarge {
            requested_byte_len: inserted.len(),
            max_byte_len,
        });
    }

    let pasted_start = insert_at + pasted_start_offset;
    let cursor = pasted_start + first_non_blank_or_line_start(&inserted[pasted_start_offset..]);

    Ok(PastePlan::new(insert_at, inserted, cursor))
}

/// Repeats characterwise register text under a byte budget.
fn counted_text(text: &str, count: Count, max_byte_len: usize) -> Result<String, PastePlanError> {
    let requested_byte_len =
        text.len()
            .checked_mul(count.get())
            .ok_or(PastePlanError::PayloadTooLarge {
                requested_byte_len: usize::MAX,
                max_byte_len,
            })?;
    if requested_byte_len > max_byte_len {
        return Err(PastePlanError::PayloadTooLarge {
            requested_byte_len,
            max_byte_len,
        });
    }

    Ok(text.repeat(count.get()))
}

/// Repeats linewise register text and normalizes each unit to a full line.
fn counted_linewise_text(
    text: &str,
    count: Count,
    max_byte_len: usize,
) -> Result<String, PastePlanError> {
    let unit_len = text.len() + usize::from(!text.ends_with('\n'));
    let requested_byte_len =
        unit_len
            .checked_mul(count.get())
            .ok_or(PastePlanError::PayloadTooLarge {
                requested_byte_len: usize::MAX,
                max_byte_len,
            })?;
    if requested_byte_len > max_byte_len {
        return Err(PastePlanError::PayloadTooLarge {
            requested_byte_len,
            max_byte_len,
        });
    }

    let mut repeated = String::with_capacity(requested_byte_len);
    for _ in 0..count.get() {
        repeated.push_str(text);
        if !text.ends_with('\n') {
            repeated.push('\n');
        }
    }
    Ok(repeated)
}

/// Returns the insertion point after a visible cursor cell.
fn next_char_boundary_for_paste(text: &str, index: usize) -> usize {
    let index = motion::clamp_to_boundary(text, index);
    let Some(character) = text[index..].chars().next() else {
        return index;
    };

    if character == '\n' {
        index
    } else {
        index + character.len_utf8()
    }
}

/// Returns the physical line start containing `index`.
fn line_start(text: &str, index: usize) -> usize {
    let index = motion::clamp_to_boundary(text, index);

    text[..index]
        .rfind('\n')
        .map_or(0, |newline_index| newline_index + '\n'.len_utf8())
}

/// Returns the byte index after the current line's newline, when present.
fn line_end_including_newline(text: &str, index: usize) -> usize {
    let index = motion::clamp_to_boundary(text, index);

    text[index..]
        .find('\n')
        .map_or(text.len(), |offset| index + offset + '\n'.len_utf8())
}

/// Returns the first non-blank offset in a line, falling back to line start.
fn first_non_blank_or_line_start(line: &str) -> usize {
    line.char_indices()
        .find_map(|(offset, character)| {
            (character != '\n' && !character.is_whitespace()).then_some(offset)
        })
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::{PastePlacement, PastePlan, PastePlanError, plan_paste};
    use crate::vim::{Count, RegisterKind, RegisterText};

    const MAX: usize = 1024;

    #[test]
    fn plans_characterwise_paste_after_utf8_cursor_cell() {
        let register = RegisterText::new("β", RegisterKind::Characterwise);

        let plan = plan_paste(
            "aλc",
            "a".len(),
            &register,
            Count::default(),
            PastePlacement::After,
            MAX,
        )
        .unwrap();

        assert_eq!(
            plan,
            PastePlan::new("".len(), String::from("β"), "".len())
        );
    }

    #[test]
    fn plans_characterwise_paste_before_cursor_cell() {
        let register = RegisterText::new("x", RegisterKind::Characterwise);

        let plan = plan_paste(
            "abc",
            1,
            &register,
            Count::default(),
            PastePlacement::Before,
            MAX,
        )
        .unwrap();

        assert_eq!(plan, PastePlan::new(1, String::from("x"), 1));
    }

    #[test]
    fn plans_linewise_paste_below_final_line_without_newline() {
        let register = RegisterText::new("two", RegisterKind::Linewise);

        let plan = plan_paste(
            "one",
            0,
            &register,
            Count::default(),
            PastePlacement::After,
            MAX,
        )
        .unwrap();

        assert_eq!(
            plan,
            PastePlan::new("one".len(), String::from("\ntwo\n"), 4)
        );
    }

    #[test]
    fn plans_linewise_paste_above_current_line() {
        let register = RegisterText::new("  one\n", RegisterKind::Linewise);

        let plan = plan_paste(
            "two\n",
            0,
            &register,
            Count::default(),
            PastePlacement::Before,
            MAX,
        )
        .unwrap();

        assert_eq!(plan, PastePlan::new(0, String::from("  one\n"), 2));
    }

    #[test]
    fn counted_paste_is_budgeted_without_allocating() {
        let register = RegisterText::new("abcd", RegisterKind::Characterwise);

        let error = plan_paste(
            "x",
            0,
            &register,
            std::num::NonZeroUsize::new(4).unwrap().into(),
            PastePlacement::After,
            15,
        )
        .unwrap_err();

        assert_eq!(
            error,
            PastePlanError::PayloadTooLarge {
                requested_byte_len: 16,
                max_byte_len: 15,
            }
        );
    }
}