fresh/primitives/
reference_highlight_text.rs1use crate::model::buffer::Buffer;
14use crate::primitives::highlight_types::HighlightSpan;
15use crate::primitives::word_navigation::{find_word_end, find_word_start, is_word_char};
16use ratatui::style::Color;
17use std::ops::Range;
18
19pub const DEFAULT_HIGHLIGHT_COLOR: Color = Color::Rgb(60, 60, 80);
21
22pub struct TextReferenceHighlighter {
27 pub highlight_color: Color,
29 pub min_word_length: usize,
31 pub enabled: bool,
33}
34
35impl Default for TextReferenceHighlighter {
36 fn default() -> Self {
37 Self {
38 highlight_color: DEFAULT_HIGHLIGHT_COLOR,
39 min_word_length: 2,
40 enabled: true,
41 }
42 }
43}
44
45impl TextReferenceHighlighter {
46 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn with_color(color: Color) -> Self {
53 Self {
54 highlight_color: color,
55 ..Self::default()
56 }
57 }
58
59 pub fn highlight_occurrences(
64 &self,
65 buffer: &Buffer,
66 cursor_position: usize,
67 viewport_start: usize,
68 viewport_end: usize,
69 ) -> Vec<HighlightSpan> {
70 if !self.enabled {
71 return Vec::new();
72 }
73
74 let word_range = match self.get_word_at_position(buffer, cursor_position) {
76 Some(range) => range,
77 None => return Vec::new(),
78 };
79
80 let word_bytes = buffer.slice_bytes(word_range.clone());
82 let word = match std::str::from_utf8(&word_bytes) {
83 Ok(s) => s.to_string(),
84 Err(_) => return Vec::new(),
85 };
86
87 if word.len() < self.min_word_length {
89 return Vec::new();
90 }
91
92 let occurrences =
94 self.find_occurrences_in_range(buffer, &word, viewport_start, viewport_end);
95
96 occurrences
98 .into_iter()
99 .map(|range| HighlightSpan {
100 range,
101 color: self.highlight_color,
102 bg: None,
103 category: None,
104 })
105 .collect()
106 }
107
108 fn get_word_at_position(&self, buffer: &Buffer, position: usize) -> Option<Range<usize>> {
110 let buf_len = buffer.len();
111 if position > buf_len {
112 return None;
113 }
114
115 let is_on_word = if position < buf_len {
117 let byte_at_pos = buffer.slice_bytes(position..position + 1);
118 byte_at_pos
119 .first()
120 .map(|&b| is_word_char(b))
121 .unwrap_or(false)
122 } else if position > 0 {
123 let byte_before = buffer.slice_bytes(position - 1..position);
125 byte_before
126 .first()
127 .map(|&b| is_word_char(b))
128 .unwrap_or(false)
129 } else {
130 false
131 };
132
133 if !is_on_word && position > 0 {
134 let byte_before = buffer.slice_bytes(position.saturating_sub(1)..position);
136 let is_after_word = byte_before
137 .first()
138 .map(|&b| is_word_char(b))
139 .unwrap_or(false);
140
141 if is_after_word && position >= buf_len {
142 let start = find_word_start(buffer, position.saturating_sub(1));
143 let end = position;
144 if start < end {
145 return Some(start..end);
146 }
147 }
148 return None;
149 }
150
151 if !is_on_word {
152 return None;
153 }
154
155 let start = find_word_start(buffer, position);
157 let end = find_word_end(buffer, position);
158
159 if start < end {
160 Some(start..end)
161 } else {
162 None
163 }
164 }
165
166 const MAX_SEARCH_RANGE: usize = 1024 * 1024;
168
169 fn find_occurrences_in_range(
171 &self,
172 buffer: &Buffer,
173 word: &str,
174 start: usize,
175 end: usize,
176 ) -> Vec<Range<usize>> {
177 if end.saturating_sub(start) > Self::MAX_SEARCH_RANGE {
179 return Vec::new();
180 }
181
182 let mut occurrences = Vec::new();
183
184 let search_start = start.saturating_sub(word.len());
186 let search_end = (end + word.len()).min(buffer.len());
187
188 let bytes = buffer.slice_bytes(search_start..search_end);
189 let text = match std::str::from_utf8(&bytes) {
190 Ok(s) => s,
191 Err(_) => return occurrences,
192 };
193
194 for (rel_pos, _) in text.match_indices(word) {
196 let abs_start = search_start + rel_pos;
197 let abs_end = abs_start + word.len();
198
199 let is_word_start = abs_start == 0 || {
201 let prev_byte = buffer.slice_bytes(abs_start - 1..abs_start);
202 prev_byte.first().map(|&b| !is_word_char(b)).unwrap_or(true)
203 };
204
205 let is_word_end = abs_end >= buffer.len() || {
206 let next_byte = buffer.slice_bytes(abs_end..abs_end + 1);
207 next_byte.first().map(|&b| !is_word_char(b)).unwrap_or(true)
208 };
209
210 if is_word_start && is_word_end {
211 if abs_start < end && abs_end > start {
213 occurrences.push(abs_start..abs_end);
214 }
215 }
216 }
217
218 occurrences
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::model::filesystem::NoopFileSystem;
226 use std::sync::Arc;
227
228 fn make_buffer(content: &str) -> Buffer {
229 let fs = Arc::new(NoopFileSystem);
230 let mut buf = Buffer::empty(fs);
231 buf.insert(0, content);
232 buf
233 }
234
235 #[test]
236 fn test_highlight_word_occurrences() {
237 let buffer = make_buffer("foo bar foo baz foo");
238 let highlighter = TextReferenceHighlighter::new();
239
240 let spans = highlighter.highlight_occurrences(&buffer, 1, 0, buffer.len());
242 assert_eq!(spans.len(), 3); }
244
245 #[test]
246 fn test_no_partial_matches() {
247 let buffer = make_buffer("foobar foo barfoo");
248 let highlighter = TextReferenceHighlighter::new();
249
250 let spans = highlighter.highlight_occurrences(&buffer, 8, 0, buffer.len());
252 assert_eq!(spans.len(), 1); }
254
255 #[test]
256 fn test_minimum_word_length() {
257 let buffer = make_buffer("a a a a");
258 let highlighter = TextReferenceHighlighter::new();
259
260 let spans = highlighter.highlight_occurrences(&buffer, 0, 0, buffer.len());
262 assert_eq!(spans.len(), 0);
263 }
264
265 #[test]
266 fn test_disabled_highlighting() {
267 let buffer = make_buffer("foo foo foo");
268 let mut highlighter = TextReferenceHighlighter::new();
269 highlighter.enabled = false;
270
271 let spans = highlighter.highlight_occurrences(&buffer, 0, 0, buffer.len());
272 assert_eq!(spans.len(), 0);
273 }
274}