fmtview 0.2.1

Fast terminal formatter and viewer for JSON, JSONL, XML-compatible markup, and formatted diffs
Documentation
use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers};

use crate::line_index::ViewFile;

use super::super::SEARCH_CHUNK_LINES;
use super::{keys::accepts_search_char, state::ViewState};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) struct SearchTarget {
    pub(in crate::viewer) line: usize,
    pub(in crate::viewer) byte_index: usize,
}

#[derive(Debug, Clone)]
pub(in crate::viewer) struct SearchTask {
    pub(in crate::viewer) query: String,
    pub(in crate::viewer) direction: SearchDirection,
    pub(in crate::viewer) next_line: usize,
    pub(in crate::viewer) remaining: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) enum SearchDirection {
    Forward,
    Backward,
}

pub(in crate::viewer) fn handle_search_input_key(
    code: KeyCode,
    modifiers: KeyModifiers,
    state: &mut ViewState,
    line_count: usize,
) -> bool {
    match code {
        KeyCode::Char(ch) if accepts_search_char(modifiers) => {
            state.search_buffer.push(ch);
            true
        }
        KeyCode::Enter => submit_search_buffer(state, line_count),
        KeyCode::Backspace => pop_search_char(state),
        KeyCode::Esc => cancel_search_prompt(state),
        _ => false,
    }
}

pub(in crate::viewer) fn start_search_prompt(state: &mut ViewState) -> bool {
    state.search_active = true;
    state.search_buffer.clear();
    state.search_message = None;
    state.search_task = None;
    state.search_target = None;
    state.search_cursor = None;
    true
}

pub(in crate::viewer) fn pop_search_char(state: &mut ViewState) -> bool {
    let old_len = state.search_buffer.len();
    state.search_buffer.pop();
    state.search_buffer.len() != old_len
}

pub(in crate::viewer) fn cancel_search_prompt(state: &mut ViewState) -> bool {
    state.search_active = false;
    state.search_buffer.clear();
    true
}

pub(in crate::viewer) fn submit_search_buffer(state: &mut ViewState, line_count: usize) -> bool {
    if state.search_buffer.is_empty() {
        return false;
    }

    let query = state.search_buffer.clone();
    state.search_active = false;
    state.search_buffer.clear();
    start_search(
        state,
        query,
        SearchDirection::Forward,
        state.top,
        line_count,
    )
}

pub(in crate::viewer) fn start_repeat_search(
    state: &mut ViewState,
    line_count: usize,
    direction: SearchDirection,
) -> bool {
    if state.search_query.is_empty() {
        state.search_message = Some("no search query".to_owned());
        return true;
    }

    let start = repeat_search_start(
        state.search_cursor.unwrap_or(state.top),
        line_count,
        direction,
    );
    start_search(
        state,
        state.search_query.clone(),
        direction,
        start,
        line_count,
    )
}

pub(in crate::viewer) fn repeat_search_start(
    top: usize,
    line_count: usize,
    direction: SearchDirection,
) -> usize {
    if line_count == 0 {
        return 0;
    }

    match direction {
        SearchDirection::Forward => top.saturating_add(1) % line_count,
        SearchDirection::Backward => top.checked_sub(1).unwrap_or(line_count - 1),
    }
}

pub(in crate::viewer) fn start_search(
    state: &mut ViewState,
    query: String,
    direction: SearchDirection,
    start_line: usize,
    line_count: usize,
) -> bool {
    if query.is_empty() {
        return false;
    }

    state.search_query = query.clone();
    state.search_message = Some(format!("searching: {query}"));
    state.search_target = None;
    state.search_cursor = None;
    if line_count == 0 {
        state.search_task = None;
        state.search_message = Some(format!("not found: {query}"));
        return true;
    }

    state.search_task = Some(SearchTask {
        query,
        direction,
        next_line: start_line.min(line_count.saturating_sub(1)),
        remaining: line_count,
    });
    true
}

pub(in crate::viewer) fn cancel_search_task(state: &mut ViewState) -> bool {
    state.search_task = None;
    state.search_target = None;
    state.search_message = Some("search canceled".to_owned());
    true
}

pub(in crate::viewer) fn clear_search_message(state: &mut ViewState) -> bool {
    let was_active = state.search_message.is_some();
    state.search_message = None;
    was_active
}

pub(in crate::viewer) fn process_search_step(
    file: &dyn ViewFile,
    state: &mut ViewState,
) -> Result<bool> {
    let Some(mut task) = state.search_task.take() else {
        return Ok(false);
    };

    let step = scan_search_chunk(file, &task)?;
    if let Some(target) = step.found {
        state.search_cursor = Some(target.line);
        state.search_target = Some(target);
        state.search_message = Some(format!("match: {}", task.query));
        return Ok(true);
    }

    task.next_line = step.next_line;
    let incomplete_index = !file.line_count_exact();
    task.remaining = task.remaining.saturating_sub(step.scanned);
    if incomplete_index
        && task.direction == SearchDirection::Forward
        && task.remaining == 0
        && step.scanned > 0
    {
        task.remaining = SEARCH_CHUNK_LINES;
    }
    if task.remaining == 0 || step.scanned == 0 {
        state.search_target = None;
        state.search_message = Some(format!("not found: {}", task.query));
        return Ok(true);
    }

    state.search_task = Some(task);
    Ok(false)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) struct SearchStep {
    pub(in crate::viewer) found: Option<SearchTarget>,
    pub(in crate::viewer) next_line: usize,
    pub(in crate::viewer) scanned: usize,
}

pub(in crate::viewer) fn scan_search_chunk(
    file: &dyn ViewFile,
    task: &SearchTask,
) -> Result<SearchStep> {
    match task.direction {
        SearchDirection::Forward => scan_search_forward(file, task),
        SearchDirection::Backward => scan_search_backward(file, task),
    }
}

pub(in crate::viewer) fn scan_search_forward(
    file: &dyn ViewFile,
    task: &SearchTask,
) -> Result<SearchStep> {
    let line_count = file.line_count();
    let exact_line_count = file.line_count_exact();
    if line_count == 0 || task.remaining == 0 {
        return Ok(SearchStep {
            found: None,
            next_line: 0,
            scanned: 0,
        });
    }

    let mut next_line = task.next_line.min(line_count - 1);
    let mut scanned = 0;
    let limit = task.remaining.min(SEARCH_CHUNK_LINES);

    while scanned < limit {
        let count = (line_count - next_line).min(limit - scanned);
        let lines = file.read_window(next_line, count)?;
        if lines.is_empty() {
            break;
        }

        for (offset, line) in lines.iter().enumerate() {
            if let Some(byte_index) = line.find(&task.query) {
                let found_line = next_line + offset;
                return Ok(SearchStep {
                    found: Some(SearchTarget {
                        line: found_line,
                        byte_index,
                    }),
                    next_line: found_line,
                    scanned: scanned + offset + 1,
                });
            }
        }

        scanned += lines.len();
        next_line = next_line.saturating_add(lines.len());
        if next_line >= line_count {
            next_line = if exact_line_count { 0 } else { line_count };
        }
    }

    Ok(SearchStep {
        found: None,
        next_line,
        scanned,
    })
}

pub(in crate::viewer) fn scan_search_backward(
    file: &dyn ViewFile,
    task: &SearchTask,
) -> Result<SearchStep> {
    let line_count = file.line_count();
    if line_count == 0 || task.remaining == 0 {
        return Ok(SearchStep {
            found: None,
            next_line: 0,
            scanned: 0,
        });
    }

    let mut next_line = task.next_line.min(line_count - 1);
    let mut scanned = 0;
    let limit = task.remaining.min(SEARCH_CHUNK_LINES);

    while scanned < limit {
        let count = (next_line + 1).min(limit - scanned);
        let start = next_line + 1 - count;
        let lines = file.read_window(start, count)?;
        if lines.is_empty() {
            break;
        }

        for (offset, line) in lines.iter().enumerate().rev() {
            if let Some(byte_index) = line.rfind(&task.query) {
                let found_line = start + offset;
                return Ok(SearchStep {
                    found: Some(SearchTarget {
                        line: found_line,
                        byte_index,
                    }),
                    next_line: found_line,
                    scanned: scanned + (count - offset),
                });
            }
        }

        scanned += lines.len();
        next_line = start.checked_sub(1).unwrap_or(line_count - 1);
    }

    Ok(SearchStep {
        found: None,
        next_line,
        scanned,
    })
}