idet-core 0.4.1

Editing logic for text editors, without a frontend
Documentation
//! Moving the current line, paragraph or markdown section up or down.

/// Direction in which [`move_segment`] shifts text.
#[derive(Clone, Copy)]
pub enum LineMove {
    /// Move towards the start of the text.
    Up,
    /// Move towards the end of the text.
    Down,
}

/// How much text [`move_segment`] treats as one unit.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Segment {
    /// The single line holding the cursor.
    Line,
    /// The run of non-blank lines around the cursor.
    Block,
    /// A markdown heading with everything below it, subsections included.
    Section,
}

/// Swaps the line at `cursor` with its neighbour in `direction`.
///
/// `cursor` is a character index; the cursor stays on the moved line. Returns
/// the new text and cursor, or `None` when there is no neighbouring line.
#[must_use]
pub fn move_line(text: &str, cursor: usize, direction: LineMove) -> Option<(String, usize)> {
    move_segment(text, cursor, Segment::Line, direction)
}

/// Swaps the segment holding `cursor` with the neighbouring one in `direction`.
///
/// `cursor` is a character index and keeps its position inside the segment as
/// it moves. Blank lines separating two blocks stay where they are. Returns
/// the new text and cursor, or `None` when the cursor is outside any segment
/// or has no neighbour to swap with.
#[must_use]
pub fn move_segment(
    text: &str,
    cursor: usize,
    segment: Segment,
    direction: LineMove,
) -> Option<(String, usize)> {
    let lines: Vec<&str> = text.split('\n').collect();
    let (line, column) = line_column(text, cursor);
    let (start, end) = segment_range(&lines, line, segment)?;
    let other = neighbour_range(&lines, start, end, segment, direction)?;
    let (swapped, moved_start) = swap_ranges(&lines, (start, end), other)?;
    let target_line = moved_start + (line - start);
    let target_column = column.min(swapped.get(target_line)?.chars().count());
    let moved_cursor = cursor_at(&swapped, target_line, target_column);
    Some((swapped.join("\n"), moved_cursor))
}

/// Moves everything `selection` touches past the neighbouring segment in
/// `direction`, as one block.
///
/// `selection` is a pair of character indices in either order and grows to
/// whole segments, so selecting part of two paragraphs moves both of them. A
/// selection ending exactly at the start of a line leaves that line out.
/// Returns the new text and the selection covering the same text again, or
/// `None` when there is nothing to swap with.
#[must_use]
pub fn move_segments(
    text: &str,
    selection: (usize, usize),
    segment: Segment,
    direction: LineMove,
) -> Option<(String, (usize, usize))> {
    let lines: Vec<&str> = text.split('\n').collect();
    let (from, to) = (selection.0.min(selection.1), selection.0.max(selection.1));
    let (first_line, first_column) = line_column(text, from);
    let (last_line, last_column) = line_column(text, to);
    let last = if last_column == 0 && last_line > first_line {
        last_line - 1
    } else {
        last_line
    };
    let (start, end) = selection_range(&lines, first_line, last, segment)?;
    let other = neighbour_range(&lines, start, end, segment, direction)?;
    let (swapped, moved_start) = swap_ranges(&lines, (start, end), other)?;
    let moved = swapped.join("\n");
    let moved_from = cursor_in(
        &moved,
        first_line.max(start) + moved_start - start,
        first_column,
    );
    let moved_to = cursor_in(
        &moved,
        last_line.min(end) + moved_start - start,
        last_column,
    );
    Some((moved, (moved_from, moved_to)))
}

fn selection_range(
    lines: &[&str],
    first: usize,
    last: usize,
    segment: Segment,
) -> Option<(usize, usize)> {
    let (first, last) = if segment == Segment::Block {
        let filled = |line: &usize| lines.get(*line).is_some_and(|text| !text.trim().is_empty());
        (
            (first..=last).find(filled)?,
            (first..=last).rev().find(filled)?,
        )
    } else {
        (first, last)
    };
    let (start, first_end) = segment_range(lines, first, segment)?;
    let (last_start, end) = segment_range(lines, last, segment)?;
    Some((start.min(last_start), end.max(first_end)))
}

fn swap_ranges<'a>(
    lines: &[&'a str],
    range: (usize, usize),
    other: (usize, usize),
) -> Option<(Vec<&'a str>, usize)> {
    let moving_first = range.0 < other.0;
    let (first, second) = if moving_first {
        (range, other)
    } else {
        (other, range)
    };
    let mut swapped: Vec<&str> = Vec::with_capacity(lines.len());
    swapped.extend_from_slice(lines.get(..first.0)?);
    swapped.extend_from_slice(lines.get(second.0..second.1)?);
    swapped.extend_from_slice(lines.get(first.1..second.0)?);
    swapped.extend_from_slice(lines.get(first.0..first.1)?);
    swapped.extend_from_slice(lines.get(second.1..)?);
    let moved_start = if moving_first {
        first.0 + (second.1 - second.0) + (second.0 - first.1)
    } else {
        first.0
    };
    Some((swapped, moved_start))
}

fn segment_range(lines: &[&str], line: usize, segment: Segment) -> Option<(usize, usize)> {
    match segment {
        Segment::Line => Some((line, line + 1)),
        Segment::Block => {
            if lines.get(line)?.trim().is_empty() {
                return None;
            }
            let start = line - filled_run(lines.iter().take(line).rev());
            let end = line + 1 + filled_run(lines.iter().skip(line + 1));
            Some((start, end))
        }
        Segment::Section => {
            let start = lines
                .iter()
                .take(line + 1)
                .rposition(|text| heading_level(text).is_some())?;
            let level = heading_level(lines.get(start)?)?;
            Some((start, section_end(lines, start, level)))
        }
    }
}

fn neighbour_range(
    lines: &[&str],
    start: usize,
    end: usize,
    segment: Segment,
    direction: LineMove,
) -> Option<(usize, usize)> {
    match (segment, direction) {
        (Segment::Line, LineMove::Up) => Some((start.checked_sub(1)?, start)),
        (Segment::Line, LineMove::Down) => (end < lines.len()).then_some((end, end + 1)),
        (Segment::Block, LineMove::Up) => {
            let other_end = start - blank_run(lines.iter().take(start).rev());
            let last = other_end.checked_sub(1)?;
            let other_start = last - filled_run(lines.iter().take(last).rev());
            Some((other_start, other_end))
        }
        (Segment::Block, LineMove::Down) => {
            let other_start = end + blank_run(lines.iter().skip(end));
            if other_start >= lines.len() {
                return None;
            }
            let other_end = other_start + 1 + filled_run(lines.iter().skip(other_start + 1));
            Some((other_start, other_end))
        }
        (Segment::Section, LineMove::Up) => {
            let level = heading_level(lines.get(start)?)?;
            let other_start = lines
                .iter()
                .take(start)
                .rposition(|text| heading_level(text).is_some())?;
            (heading_level(lines.get(other_start)?) == Some(level)).then_some((other_start, start))
        }
        (Segment::Section, LineMove::Down) => {
            let level = heading_level(lines.get(start)?)?;
            (heading_level(lines.get(end)?) == Some(level))
                .then(|| (end, section_end(lines, end, level)))
        }
    }
}

fn blank_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
    lines.take_while(|text| text.trim().is_empty()).count()
}

fn filled_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
    lines.take_while(|text| !text.trim().is_empty()).count()
}

fn section_end(lines: &[&str], start: usize, level: usize) -> usize {
    lines
        .iter()
        .enumerate()
        .skip(start + 1)
        .find(|(_, text)| heading_level(text).is_some_and(|found| found <= level))
        .map_or(lines.len(), |(index, _)| index)
}

fn heading_level(line: &str) -> Option<usize> {
    let hashes = line.chars().take_while(|&c| c == '#').count();
    (hashes > 0 && line.chars().nth(hashes) == Some(' ')).then_some(hashes)
}

/// Locates the zero-indexed `(line, column)` of the character at `cursor`.
#[must_use]
pub fn line_column(text: &str, cursor: usize) -> (usize, usize) {
    let mut line = 0;
    let mut column = 0;
    for (index, character) in text.chars().enumerate() {
        if index == cursor {
            break;
        }
        if character == '\n' {
            line += 1;
            column = 0;
        } else {
            column += 1;
        }
    }
    (line, column)
}

/// Converts a `(line, column)` position into a character index into `text`,
/// clamped to the last line and to the width of the line it lands on.
#[must_use]
pub fn cursor_in(text: &str, line: usize, column: usize) -> usize {
    let lines: Vec<&str> = text.split('\n').collect();
    let line = line.min(lines.len().saturating_sub(1));
    let width = lines.get(line).map_or(0, |text| text.chars().count());
    cursor_at(&lines, line, column.min(width))
}

/// Converts a `(line, column)` position back into a character index, given
/// `text` already split on `\n`.
#[must_use]
pub fn cursor_at(lines: &[&str], line: usize, column: usize) -> usize {
    let preceding: usize = lines
        .iter()
        .take(line)
        .map(|text| text.chars().count() + 1)
        .sum();
    preceding + column
}