idet-core 0.4.0

Editing logic for text editors, without a frontend
Documentation
//! Text insertion and indentation edits, all character-index based.

use crate::completion::char_to_byte;

/// Inserts `insertion` at character index `cursor`, returning the new text and
/// the cursor placed after the inserted text.
#[must_use]
pub fn insert(text: &str, cursor: usize, insertion: &str) -> (String, usize) {
    let byte = char_to_byte(text, cursor);
    let mut result = text.to_owned();
    result.insert_str(byte, insertion);
    (result, cursor + insertion.chars().count())
}

/// Inserts a newline at `cursor` followed by the leading whitespace of the
/// current line, reproducing that line's indentation.
#[must_use]
pub fn newline_indent(text: &str, cursor: usize) -> (String, usize) {
    let line_start = line_start_char(text, cursor);
    let indent: String = text
        .chars()
        .skip(line_start)
        .take_while(|c| *c == ' ' || *c == '\t')
        .collect();
    let mut insertion = String::with_capacity(indent.len() + 1);
    insertion.push('\n');
    insertion.push_str(&indent);
    insert(text, cursor, &insertion)
}

/// Prepends `width` spaces to the current line, shifting the cursor
/// accordingly.
#[must_use]
pub fn indent_line(text: &str, cursor: usize, width: usize) -> (String, usize) {
    let start = line_start_char(text, cursor);
    let (result, _) = insert(text, start, &" ".repeat(width));
    (result, cursor + width)
}

/// Removes up to `width` leading spaces from the current line, clamping the
/// cursor to the line start when it sat inside the removed indentation.
#[must_use]
pub fn dedent_line(text: &str, cursor: usize, width: usize) -> (String, usize) {
    let start = line_start_char(text, cursor);
    let removable = text
        .chars()
        .skip(start)
        .take(width)
        .take_while(|c| *c == ' ')
        .count();
    if removable == 0 {
        return (text.to_owned(), cursor);
    }
    let start_byte = char_to_byte(text, start);
    let end_byte = char_to_byte(text, start + removable);
    let mut result = text.to_owned();
    result.replace_range(start_byte..end_byte, "");
    let new_cursor = if cursor >= start + removable {
        cursor - removable
    } else {
        start
    };
    (result, new_cursor)
}

/// Returns the span of `after` that differs from `before`.
///
/// The common prefix and suffix are stripped, so what remains is the text a
/// history step brought in: select it to show what an undo restored. An empty
/// span means the step only removed text and marks where it went.
#[must_use]
pub fn changed_range(before: &str, after: &str) -> (usize, usize) {
    let before_len = before.chars().count();
    let after_len = after.chars().count();
    let prefix = before
        .chars()
        .zip(after.chars())
        .take_while(|(old, new)| old == new)
        .count();
    let suffix = before
        .chars()
        .rev()
        .zip(after.chars().rev())
        .take_while(|(old, new)| old == new)
        .count()
        .min(before_len.min(after_len) - prefix);
    (prefix, after_len - suffix)
}

/// Returns the character index of the start of the line containing `cursor`.
#[must_use]
pub fn line_start_char(text: &str, cursor: usize) -> usize {
    text.chars()
        .take(cursor)
        .enumerate()
        .filter_map(|(index, character)| (character == '\n').then_some(index + 1))
        .last()
        .unwrap_or(0)
}

/// Returns the character index of the end of the line containing `cursor`.
#[must_use]
pub fn line_end_char(text: &str, cursor: usize) -> usize {
    text.chars()
        .skip(cursor)
        .position(|character| character == '\n')
        .map_or_else(|| text.chars().count(), |offset| cursor + offset)
}