Skip to main content

kimun_notes/components/
preview_highlight.rs

1//! Shared text helpers for the note-preview surfaces (the Query panel's context
2//! preview and the note browser's preview pane). Both highlight query needles in
3//! a note's body and wrap long lines; the case-insensitive matching is the one
4//! piece that is both subtle (must stay on character boundaries for every
5//! non-ASCII case fold) and was previously implemented twice — once correctly,
6//! once dropping highlights for length-changing folds. It lives here now; each
7//! surface keeps its own span styling.
8
9/// Non-overlapping byte ranges in `haystack` where any of `needles` matches,
10/// case-insensitively, earliest- and longest-first.
11///
12/// Matching is character-based, so every returned offset is a real `char`
13/// boundary of `haystack` — slicing `haystack[start..end]` never panics, even
14/// when a case fold changes byte length (`İ`, `ẞ`) or shifts boundaries. Empty
15/// needles contribute nothing. Per needle, matches are non-overlapping (like
16/// `str::match_indices`); across needles, a later range that overlaps one
17/// already kept is dropped, with the longest range at each start winning.
18pub fn match_ranges(haystack: &str, needles: &[String]) -> Vec<(usize, usize)> {
19    // Built once and shared across all needles — the haystack is the same.
20    let hay: Vec<(usize, char)> = haystack.char_indices().collect();
21    let mut ranges: Vec<(usize, usize)> = Vec::new();
22    for needle in needles {
23        collect_needle(&hay, haystack.len(), needle, &mut ranges);
24    }
25    // Longest match first at each start, so an overlapping shorter needle never
26    // truncates a longer one.
27    ranges.sort_unstable_by_key(|(s, e)| (*s, std::cmp::Reverse(*e)));
28    ranges.dedup();
29
30    let mut kept: Vec<(usize, usize)> = Vec::new();
31    let mut pos = 0;
32    for (start, end) in ranges {
33        if start < pos {
34            continue; // overlaps a range already kept
35        }
36        kept.push((start, end));
37        pos = end;
38    }
39    kept
40}
41
42/// Append every non-overlapping case-insensitive occurrence of `needle` to
43/// `out`, as byte ranges into the original string. `hay` is its precomputed
44/// `char_indices`, `hay_len` its byte length (the tail boundary).
45fn collect_needle(
46    hay: &[(usize, char)],
47    hay_len: usize,
48    needle: &str,
49    out: &mut Vec<(usize, usize)>,
50) {
51    let needle_chars: Vec<char> = needle.chars().collect();
52    if needle_chars.is_empty() {
53        return;
54    }
55    let n = needle_chars.len();
56    let mut i = 0;
57    while i + n <= hay.len() {
58        if (0..n).all(|j| chars_eq_ignore_case(hay[i + j].1, needle_chars[j])) {
59            let start = hay[i].0;
60            let end = hay.get(i + n).map(|(b, _)| *b).unwrap_or(hay_len);
61            out.push((start, end));
62            i += n; // non-overlapping, matching `str::match_indices`
63        } else {
64            i += 1;
65        }
66    }
67}
68
69/// Case-insensitive single-character compare that handles multi-character folds
70/// (e.g. `ẞ`/`ß`) by comparing the full lowercase mappings.
71fn chars_eq_ignore_case(a: char, b: char) -> bool {
72    a == b || a.to_lowercase().eq(b.to_lowercase())
73}
74
75/// Walk `line` against precomputed non-overlapping `ranges` (from
76/// [`match_ranges`], ascending) and emit one item per segment via
77/// `mk(slice, is_match)` — alternating non-match gaps and matched spans, plus
78/// the trailing gap. Empty `ranges` yields the whole line as a single non-match
79/// segment. Shared so the gap/match/tail splitting lives in one place; each
80/// caller supplies its own constructor (owned `'static` spans vs borrowed `'a`
81/// spans, with its own styles).
82pub fn style_ranges<'a, T>(
83    line: &'a str,
84    ranges: &[(usize, usize)],
85    mut mk: impl FnMut(&'a str, bool) -> T,
86) -> Vec<T> {
87    let mut out = Vec::new();
88    let mut pos = 0;
89    for &(start, end) in ranges {
90        if start > pos {
91            out.push(mk(&line[pos..start], false));
92        }
93        out.push(mk(&line[start..end], true));
94        pos = end;
95    }
96    if pos < line.len() {
97        out.push(mk(&line[pos..], false));
98    }
99    out
100}
101
102/// Wrap `line` into pieces that each fit within `max_width` *characters* (not
103/// bytes). Breaks at word boundaries when possible, hard-breaks an
104/// over-long word otherwise. A `max_width` of 0 returns the line unchanged.
105pub fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
106    if max_width == 0 || line.chars().count() <= max_width {
107        return vec![line.to_string()];
108    }
109
110    let mut result = Vec::new();
111    let mut remaining = line;
112
113    while remaining.chars().count() > max_width {
114        // Byte index of the `max_width`-th character.
115        let byte_limit = remaining
116            .char_indices()
117            .nth(max_width)
118            .map(|(i, _)| i)
119            .unwrap_or(remaining.len());
120
121        // Prefer a space within the allowed range; hard-break if none.
122        let break_at = remaining[..byte_limit]
123            .rfind(' ')
124            .map(|i| i + 1) // keep the space on the current line
125            .unwrap_or(byte_limit);
126        result.push(remaining[..break_at].trim_end().to_string());
127        remaining = &remaining[break_at..];
128    }
129    if !remaining.is_empty() {
130        result.push(remaining.to_string());
131    }
132    result
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn needles(v: &[&str]) -> Vec<String> {
140        v.iter().map(|s| s.to_string()).collect()
141    }
142
143    #[test]
144    fn matches_are_case_insensitive() {
145        assert_eq!(match_ranges("Hello World", &needles(&["world"])), [(6, 11)]);
146        assert_eq!(match_ranges("HELLO", &needles(&["hell"])), [(0, 4)]);
147    }
148
149    #[test]
150    fn all_occurrences_per_needle() {
151        // `aa` appears at byte 0 and 3 (non-overlapping, like match_indices).
152        assert_eq!(match_ranges("aa aa", &needles(&["aa"])), [(0, 2), (3, 5)]);
153    }
154
155    #[test]
156    fn overlapping_needles_keep_longest() {
157        // "foobar" and "foo" both start at 0; the longer wins, the shorter is
158        // dropped as overlapping.
159        let r = match_ranges("foobar", &needles(&["foo", "foobar"]));
160        assert_eq!(r, [(0, 6)]);
161    }
162
163    #[test]
164    fn empty_needles_contribute_nothing() {
165        assert!(match_ranges("anything", &needles(&[""])).is_empty());
166        assert!(match_ranges("anything", &[]).is_empty());
167    }
168
169    #[test]
170    fn ascii_needle_matches_on_line_containing_a_length_changing_fold() {
171        // `İ` (U+0130) lowercases to a longer string, so the old
172        // `lower.len() != line.len()` bail dropped highlighting for the WHOLE
173        // line — including the plain ASCII "there". Char-based matching finds
174        // it regardless, on real char boundaries (no panic).
175        let hay = "Hİ there";
176        let r = match_ranges(hay, &needles(&["there"]));
177        assert_eq!(r.len(), 1, "ascii needle must still match: {r:?}");
178        let (s, e) = r[0];
179        assert!(hay.is_char_boundary(s) && hay.is_char_boundary(e));
180        assert_eq!(&hay[s..e], "there");
181    }
182
183    #[test]
184    fn multibyte_haystack_offsets_are_valid() {
185        let hay = "日本語テスト";
186        let r = match_ranges(hay, &needles(&["テスト"]));
187        assert_eq!(r.len(), 1);
188        let (s, e) = r[0];
189        assert_eq!(&hay[s..e], "テスト");
190    }
191
192    #[test]
193    fn style_ranges_alternates_gaps_and_matches() {
194        let line = "see widget and gadget";
195        let ranges = match_ranges(line, &needles(&["widget", "gadget"]));
196        let segs: Vec<(String, bool)> = style_ranges(line, &ranges, |s, hit| (s.to_string(), hit));
197        // Every match becomes a `true` segment, gaps `false` — not just the first.
198        assert_eq!(
199            segs,
200            vec![
201                ("see ".to_string(), false),
202                ("widget".to_string(), true),
203                (" and ".to_string(), false),
204                ("gadget".to_string(), true),
205            ]
206        );
207    }
208
209    #[test]
210    fn style_ranges_empty_is_one_non_match_segment() {
211        let segs: Vec<(String, bool)> =
212            style_ranges("no matches here", &[], |s, hit| (s.to_string(), hit));
213        assert_eq!(segs, vec![("no matches here".to_string(), false)]);
214    }
215
216    #[test]
217    fn wrap_line_fits_within_width() {
218        assert_eq!(wrap_line("short", 20), vec!["short"]);
219    }
220
221    #[test]
222    fn wrap_line_breaks_at_word_boundary() {
223        assert_eq!(
224            wrap_line("hello world foo bar", 12),
225            vec!["hello world", "foo bar"]
226        );
227    }
228
229    #[test]
230    fn wrap_line_hard_breaks_long_word() {
231        assert_eq!(wrap_line("abcdefghij", 5), vec!["abcde", "fghij"]);
232    }
233
234    #[test]
235    fn wrap_line_handles_multibyte_chars() {
236        assert_eq!(wrap_line("日本語テスト", 3), vec!["日本語", "テスト"]);
237    }
238
239    #[test]
240    fn wrap_line_empty_string() {
241        assert_eq!(wrap_line("", 10), vec![""]);
242    }
243}