vorto 0.4.0

A terminal text editor with tree-sitter syntax highlighting and LSP support
use crate::editor::{Buffer, Cursor};

#[derive(Debug, Default)]
pub struct SearchState {
    pub query: String,
    pub last_forward: bool,
}

impl SearchState {
    pub fn set(&mut self, query: String, forward: bool) {
        self.query = query;
        self.last_forward = forward;
    }

    pub fn find_next(&self, buffer: &Buffer, forward: bool) -> Option<Cursor> {
        if self.query.is_empty() {
            return None;
        }
        if forward {
            find_forward(buffer, &self.query)
        } else {
            find_backward(buffer, &self.query)
        }
    }

    /// Find the next/previous match's full range relative to the cursor.
    /// Returns `(start, end_inclusive)` — the cursor positions of the
    /// first and last char of the match. Used by `gn` / `gN` to select
    /// the match in Visual mode.
    ///
    /// Unlike `find_next`, the forward variant searches from the cursor
    /// position (inclusive), so a cursor sitting at the start of a match
    /// selects that match instead of skipping to the next one.
    pub fn find_match_range(
        &self,
        buffer: &Buffer,
        forward: bool,
    ) -> Option<(Cursor, Cursor)> {
        if self.query.is_empty() {
            return None;
        }
        let start = if forward {
            find_forward_inclusive(buffer, &self.query)
        } else {
            find_backward_inclusive(buffer, &self.query)
        }?;
        let end = match_end_inclusive(buffer, &self.query, start)?;
        Some((start, end))
    }
}

fn find_forward(buffer: &Buffer, query: &str) -> Option<Cursor> {
    let start_row = buffer.cursor.row;
    let start_col = buffer.cursor.col + 1;

    for (offset, _) in buffer
        .lines
        .iter()
        .enumerate()
        .cycle()
        .take(buffer.lines.len() + 1)
    {
        let row = (start_row + offset) % buffer.lines.len();
        let line = &buffer.lines[row];
        let search_from_byte = if offset == 0 {
            char_to_byte(line, start_col)
        } else {
            0
        };
        if search_from_byte > line.len() {
            continue;
        }
        if let Some(byte_idx) = line[search_from_byte..].find(query) {
            let abs_byte = search_from_byte + byte_idx;
            let col = byte_to_char(line, abs_byte);
            return Some(Cursor { row, col });
        }
    }
    None
}

fn find_backward(buffer: &Buffer, query: &str) -> Option<Cursor> {
    let n = buffer.lines.len();
    let start_row = buffer.cursor.row;
    let start_col = buffer.cursor.col;

    for offset in 0..=n {
        let row = (start_row + n - offset) % n;
        let line = &buffer.lines[row];
        let search_until_byte = if offset == 0 {
            char_to_byte(line, start_col)
        } else {
            line.len()
        };
        if let Some(byte_idx) = line[..search_until_byte].rfind(query) {
            let col = byte_to_char(line, byte_idx);
            return Some(Cursor { row, col });
        }
    }
    None
}

fn find_forward_inclusive(buffer: &Buffer, query: &str) -> Option<Cursor> {
    let start_row = buffer.cursor.row;
    let start_col = buffer.cursor.col;

    for (offset, _) in buffer
        .lines
        .iter()
        .enumerate()
        .cycle()
        .take(buffer.lines.len() + 1)
    {
        let row = (start_row + offset) % buffer.lines.len();
        let line = &buffer.lines[row];
        let search_from_byte = if offset == 0 {
            char_to_byte(line, start_col)
        } else {
            0
        };
        if search_from_byte > line.len() {
            continue;
        }
        if let Some(byte_idx) = line[search_from_byte..].find(query) {
            let abs_byte = search_from_byte + byte_idx;
            let col = byte_to_char(line, abs_byte);
            return Some(Cursor { row, col });
        }
    }
    None
}

fn find_backward_inclusive(buffer: &Buffer, query: &str) -> Option<Cursor> {
    let n = buffer.lines.len();
    let start_row = buffer.cursor.row;
    let start_col = buffer.cursor.col;
    let query_chars = query.chars().count();

    for offset in 0..=n {
        let row = (start_row + n - offset) % n;
        let line = &buffer.lines[row];
        // On the cursor row, extend the search window forward by the
        // query length so a match starting at or before the cursor is
        // still inside the slice `rfind` scans.
        let search_until_byte = if offset == 0 {
            char_to_byte(line, start_col + query_chars).min(line.len())
        } else {
            line.len()
        };
        if let Some(byte_idx) = line[..search_until_byte].rfind(query) {
            let col = byte_to_char(line, byte_idx);
            return Some(Cursor { row, col });
        }
    }
    None
}

/// Given a match start, compute the cursor of the last char of the
/// match. Assumes `query` matches at `start` on a single line.
fn match_end_inclusive(buffer: &Buffer, query: &str, start: Cursor) -> Option<Cursor> {
    let line = buffer.lines.get(start.row)?;
    let len = query.chars().count();
    if len == 0 {
        return None;
    }
    let line_chars = line.chars().count();
    let end_col = start.col + len - 1;
    let end_col = end_col.min(line_chars.saturating_sub(1));
    Some(Cursor {
        row: start.row,
        col: end_col,
    })
}

fn char_to_byte(s: &str, char_idx: usize) -> usize {
    s.char_indices()
        .nth(char_idx)
        .map(|(b, _)| b)
        .unwrap_or(s.len())
}

fn byte_to_char(s: &str, byte_idx: usize) -> usize {
    s[..byte_idx].chars().count()
}