diffler 0.20.0

Standalone magit-style git TUI and code reviewer, on its own or with your coding agent
Documentation
//! Vim-style `/` search over a pane's `(row index, text)` rows: plain substring
//! with smartcase, case-insensitive unless the query has an uppercase letter.

use std::ops::Range;

/// `row` is the pane's own row index; `range` is bytes within that row's text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
    pub row: usize,
    pub range: Range<usize>,
}

pub struct Search {
    query: String,
    /// The prompt is capturing input; after commit, highlights and `n`/`N` stay.
    pub open: bool,
    matches: Vec<Match>,
    current: usize,
    /// The first match is picked from here, and cancel returns the cursor here.
    origin_row: usize,
}

impl Search {
    pub fn start(origin_row: usize) -> Self {
        Self {
            query: String::new(),
            open: true,
            matches: Vec::new(),
            current: 0,
            origin_row,
        }
    }

    pub fn query(&self) -> &str {
        &self.query
    }

    pub fn origin_row(&self) -> usize {
        self.origin_row
    }

    /// `(1-based active, total)`; `(0, 0)` when there are no matches.
    pub fn count(&self) -> (usize, usize) {
        if self.matches.is_empty() {
            (0, 0)
        } else {
            (self.current + 1, self.matches.len())
        }
    }

    pub fn insert(&mut self, c: char) {
        self.query.push(c);
    }

    pub fn backspace(&mut self) {
        self.query.pop();
    }

    /// Re-run whenever the rows change so highlights track the live content.
    pub fn recompute(&mut self, rows: &[(usize, String)]) {
        self.reseat(rows, self.origin_row, true);
    }

    /// The active match is the first at or after `at` (`forward`), else the
    /// last at or before it.
    pub fn reseat(&mut self, rows: &[(usize, String)], at: usize, forward: bool) {
        self.matches = find_matches(rows, &self.query);
        let found = if forward {
            self.matches.iter().position(|m| m.row >= at)
        } else {
            self.matches.iter().rposition(|m| m.row <= at)
        };
        self.current = found.unwrap_or(0);
    }

    pub fn commit(&mut self) -> Option<usize> {
        self.open = false;
        self.active_row()
    }

    pub fn next_match(&mut self) -> Option<usize> {
        if self.matches.is_empty() {
            return None;
        }
        self.current = (self.current + 1) % self.matches.len();
        self.active_row()
    }

    pub fn prev_match(&mut self) -> Option<usize> {
        if self.matches.is_empty() {
            return None;
        }
        self.current = (self.current + self.matches.len() - 1) % self.matches.len();
        self.active_row()
    }

    fn active_row(&self) -> Option<usize> {
        self.matches.get(self.current).map(|m| m.row)
    }

    pub fn current_row(&self) -> Option<usize> {
        self.active_row()
    }

    pub fn match_rows(&self) -> Vec<usize> {
        let mut rows: Vec<usize> = self.matches.iter().map(|m| m.row).collect();
        rows.dedup();
        rows
    }

    pub fn ranges_for(&self, row: usize) -> Vec<(Range<usize>, bool)> {
        self.matches
            .iter()
            .enumerate()
            .filter(|(_, m)| m.row == row)
            .map(|(i, m)| (m.range.clone(), i == self.current))
            .collect()
    }
}

/// ASCII case-folding keeps byte length, so a folded match's range indexes the
/// original text unchanged.
pub fn find_matches(rows: &[(usize, String)], query: &str) -> Vec<Match> {
    if query.is_empty() {
        return Vec::new();
    }
    let case_insensitive = !query.chars().any(char::is_uppercase);
    let needle = fold(query, case_insensitive);
    let mut out = Vec::new();
    for (row, text) in rows {
        let hay = fold(text, case_insensitive);
        let mut from = 0;
        while let Some(pos) = hay.get(from..).and_then(|h| h.find(&needle)) {
            let start = from + pos;
            let end = start + needle.len();
            out.push(Match {
                row: *row,
                range: start..end,
            });
            from = end.max(start + 1);
        }
    }
    out
}

fn fold(s: &str, case_insensitive: bool) -> String {
    if case_insensitive {
        s.to_ascii_lowercase()
    } else {
        s.to_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rows(items: &[(usize, &str)]) -> Vec<(usize, String)> {
        items.iter().map(|(r, t)| (*r, (*t).to_owned())).collect()
    }

    fn search(origin: usize, query: &str, rows: &[(usize, String)]) -> Search {
        let mut s = Search::start(origin);
        for c in query.chars() {
            s.insert(c);
        }
        s.recompute(rows);
        s
    }

    #[test]
    fn smartcase_is_insensitive_until_an_uppercase_appears() {
        let r = rows(&[(0, "Stall Width"), (1, "stallwidth")]);
        assert_eq!(find_matches(&r, "stall").len(), 2);
        assert_eq!(find_matches(&r, "Stall").len(), 1);
    }

    #[test]
    fn matches_carry_byte_ranges_in_the_original_text() {
        let r = rows(&[(7, "let width = 1;")]);
        let m = find_matches(&r, "width");
        assert_eq!(
            m,
            vec![Match {
                row: 7,
                range: 4..9
            }]
        );
    }

    #[test]
    fn multiple_matches_per_row_do_not_overlap() {
        let r = rows(&[(0, "aaa")]);
        let m = find_matches(&r, "aa");
        assert_eq!(m.len(), 1, "non-overlapping scan: {m:?}");
    }

    #[test]
    fn empty_query_matches_nothing() {
        assert!(find_matches(&rows(&[(0, "anything")]), "").is_empty());
    }

    #[test]
    fn next_and_prev_wrap_and_track_the_active_row() {
        let r = rows(&[(0, "x"), (2, "x"), (5, "x")]);
        let mut s = search(0, "x", &r);
        assert_eq!(s.count(), (1, 3));
        assert_eq!(s.next_match(), Some(2));
        assert_eq!(s.next_match(), Some(5));
        assert_eq!(s.next_match(), Some(0), "wraps to the first");
        assert_eq!(s.prev_match(), Some(5), "wraps to the last");
    }

    #[test]
    fn active_match_is_the_first_at_or_after_the_origin() {
        let r = rows(&[(0, "x"), (4, "x"), (9, "x")]);
        let mut s = search(4, "x", &r);
        assert_eq!(s.commit(), Some(4));
    }

    #[test]
    fn ranges_for_flags_the_active_match() {
        let r = rows(&[(1, "foo foo")]);
        let s = search(0, "foo", &r);
        let ranges = s.ranges_for(1);
        assert_eq!(ranges.len(), 2);
        assert!(ranges[0].1, "first is active");
        assert!(!ranges[1].1);
    }

    #[test]
    fn backspace_shrinks_the_query() {
        let r = rows(&[(0, "alpha"), (1, "beta")]);
        let mut s = search(0, "al", &r);
        assert_eq!(s.count(), (1, 1));
        s.backspace();
        s.recompute(&r);
        assert_eq!(s.query(), "a");
        assert_eq!(s.count().1, 3, "two 'a' in alpha, one in beta");
    }
}