alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Word-wise motion helpers.

use super::clamp_to_boundary;

/// Returns `true` when a character is part of a Vim-style word.
fn is_keyword_character(character: char) -> bool {
    character == '_' || character.is_alphanumeric()
}

/// Vim word classes for lowercase word motions.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WordClass {
    /// Alphanumeric and underscore keyword runs.
    Keyword,
    /// Non-whitespace, non-keyword punctuation runs.
    Punctuation,
}

impl WordClass {
    /// Lowercase word class.
    fn normal(character: char) -> Option<Self> {
        if character.is_whitespace() {
            None
        } else if is_keyword_character(character) {
            Some(Self::Keyword)
        } else {
            Some(Self::Punctuation)
        }
    }

    /// Uppercase WORD class.
    fn big(character: char) -> Option<Self> {
        (!character.is_whitespace()).then_some(Self::Keyword)
    }
}

/// Moves to the start of the next word.
pub(super) fn next_word_start(text: &str, index: usize) -> usize {
    next_classified_word_start(text, index, WordClass::normal)
}

/// Moves to the start of the next WORD.
pub(super) fn next_big_word_start(text: &str, index: usize) -> usize {
    next_classified_word_start(text, index, WordClass::big)
}

/// Moves to the start of the previous word.
pub(super) fn previous_word_start(text: &str, index: usize) -> usize {
    previous_classified_word_start(text, index, WordClass::normal)
}

/// Moves to the start of the previous WORD.
pub(super) fn previous_big_word_start(text: &str, index: usize) -> usize {
    previous_classified_word_start(text, index, WordClass::big)
}

/// Moves to the end of the current or next word.
pub(super) fn next_word_end(text: &str, index: usize) -> usize {
    next_classified_word_end(text, index, WordClass::normal)
}

/// Moves to the end of the current or next WORD.
pub(super) fn next_big_word_end(text: &str, index: usize) -> usize {
    next_classified_word_end(text, index, WordClass::big)
}

/// Moves to the end of the previous word.
pub(super) fn previous_word_end(text: &str, index: usize) -> usize {
    previous_classified_word_end(text, index, WordClass::normal)
}

/// Moves to the end of the previous WORD.
pub(super) fn previous_big_word_end(text: &str, index: usize) -> usize {
    previous_classified_word_end(text, index, WordClass::big)
}

/// Next classified word start.
fn next_classified_word_start(
    text: &str,
    index: usize,
    classify: fn(char) -> Option<WordClass>,
) -> usize {
    let index = clamp_to_boundary(text, index);
    let current_class = text[index..].chars().next().and_then(classify);
    let mut previous_class = current_class;
    let mut left_current_word = current_class.is_none();

    for (offset, character) in text[index..].char_indices() {
        let byte_index = index + offset;
        let class = classify(character);

        if byte_index == index {
            continue;
        }

        match (previous_class, class) {
            (_, None) => left_current_word = true,
            (Some(previous), Some(current)) if previous != current => return byte_index,
            (None, Some(_)) if left_current_word => return byte_index,
            _ => {}
        }

        previous_class = class;
    }

    text.len()
}

/// Previous classified word start.
fn previous_classified_word_start(
    text: &str,
    index: usize,
    classify: fn(char) -> Option<WordClass>,
) -> usize {
    let index = clamp_to_boundary(text, index);
    word_runs(text, classify)
        .into_iter()
        .take_while(|run| run.start < index)
        .last()
        .map_or(0, |run| run.start)
}

/// Current-or-next classified word end.
fn next_classified_word_end(
    text: &str,
    index: usize,
    classify: fn(char) -> Option<WordClass>,
) -> usize {
    let index = clamp_to_boundary(text, index);
    for run in word_runs(text, classify) {
        if index < run.start {
            return run.end;
        }

        if index <= run.end {
            return if index < run.end {
                run.end
            } else {
                word_runs(text, classify)
                    .into_iter()
                    .find(|candidate| candidate.start > run.start)
                    .map_or(run.end, |candidate| candidate.end)
            };
        }
    }

    text.len()
}

/// Previous classified word end.
fn previous_classified_word_end(
    text: &str,
    index: usize,
    classify: fn(char) -> Option<WordClass>,
) -> usize {
    let index = clamp_to_boundary(text, index);
    let runs = word_runs(text, classify);
    for (run_index, run) in runs.iter().enumerate().rev() {
        if index > run.end {
            return run.end;
        }

        if index > run.start {
            return run_index
                .checked_sub(1)
                .and_then(|previous| runs.get(previous))
                .map_or(0, |previous| previous.end);
        }
    }

    0
}

/// Classified word run.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct WordRun {
    /// First byte of the run.
    start: usize,
    /// First byte of the last char.
    end: usize,
    /// Run class.
    class: WordClass,
}

/// Returns all classified word runs in `text`.
fn word_runs(text: &str, classify: fn(char) -> Option<WordClass>) -> Vec<WordRun> {
    let mut runs = Vec::new();
    let mut current: Option<WordRun> = None;

    for (byte_index, character) in text.char_indices() {
        match (current, classify(character)) {
            (Some(mut run), Some(class)) if run.class == class => {
                run.end = byte_index;
                current = Some(run);
            }
            (Some(run), Some(class)) => {
                runs.push(run);
                current = Some(WordRun {
                    start: byte_index,
                    end: byte_index,
                    class,
                });
            }
            (None, Some(class)) => {
                current = Some(WordRun {
                    start: byte_index,
                    end: byte_index,
                    class,
                });
            }
            (Some(run), None) => {
                runs.push(run);
                current = None;
            }
            (None, None) => {}
        }
    }

    if let Some(run) = current {
        runs.push(run);
    }

    runs
}