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 category: None,
103 })
104 .collect()
105 }
106
107 fn get_word_at_position(&self, buffer: &Buffer, position: usize) -> Option<Range<usize>> {
109 let buf_len = buffer.len();
110 if position > buf_len {
111 return None;
112 }
113
114 let is_on_word = if position < buf_len {
116 let byte_at_pos = buffer.slice_bytes(position..position + 1);
117 byte_at_pos
118 .first()
119 .map(|&b| is_word_char(b))
120 .unwrap_or(false)
121 } else if position > 0 {
122 let byte_before = buffer.slice_bytes(position - 1..position);
124 byte_before
125 .first()
126 .map(|&b| is_word_char(b))
127 .unwrap_or(false)
128 } else {
129 false
130 };
131
132 if !is_on_word && position > 0 {
133 let byte_before = buffer.slice_bytes(position.saturating_sub(1)..position);
135 let is_after_word = byte_before
136 .first()
137 .map(|&b| is_word_char(b))
138 .unwrap_or(false);
139
140 if is_after_word && position >= buf_len {
141 let start = find_word_start(buffer, position.saturating_sub(1));
142 let end = position;
143 if start < end {
144 return Some(start..end);
145 }
146 }
147 return None;
148 }
149
150 if !is_on_word {
151 return None;
152 }
153
154 let start = find_word_start(buffer, position);
156 let end = find_word_end(buffer, position);
157
158 if start < end {
159 Some(start..end)
160 } else {
161 None
162 }
163 }
164
165 const MAX_SEARCH_RANGE: usize = 1024 * 1024;
167
168 fn find_occurrences_in_range(
170 &self,
171 buffer: &Buffer,
172 word: &str,
173 start: usize,
174 end: usize,
175 ) -> Vec<Range<usize>> {
176 if end.saturating_sub(start) > Self::MAX_SEARCH_RANGE {
178 return Vec::new();
179 }
180
181 let mut occurrences = Vec::new();
182
183 let search_start = start.saturating_sub(word.len());
185 let search_end = (end + word.len()).min(buffer.len());
186
187 let bytes = buffer.slice_bytes(search_start..search_end);
188 let text = match std::str::from_utf8(&bytes) {
189 Ok(s) => s,
190 Err(_) => return occurrences,
191 };
192
193 for (rel_pos, _) in text.match_indices(word) {
195 let abs_start = search_start + rel_pos;
196 let abs_end = abs_start + word.len();
197
198 let is_word_start = abs_start == 0 || {
200 let prev_byte = buffer.slice_bytes(abs_start - 1..abs_start);
201 prev_byte.first().map(|&b| !is_word_char(b)).unwrap_or(true)
202 };
203
204 let is_word_end = abs_end >= buffer.len() || {
205 let next_byte = buffer.slice_bytes(abs_end..abs_end + 1);
206 next_byte.first().map(|&b| !is_word_char(b)).unwrap_or(true)
207 };
208
209 if is_word_start && is_word_end {
210 if abs_start < end && abs_end > start {
212 occurrences.push(abs_start..abs_end);
213 }
214 }
215 }
216
217 occurrences
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::model::filesystem::NoopFileSystem;
225 use std::sync::Arc;
226
227 fn make_buffer(content: &str) -> Buffer {
228 let fs = Arc::new(NoopFileSystem);
229 let mut buf = Buffer::empty(fs);
230 buf.insert(0, content);
231 buf
232 }
233
234 #[test]
235 fn test_highlight_word_occurrences() {
236 let buffer = make_buffer("foo bar foo baz foo");
237 let highlighter = TextReferenceHighlighter::new();
238
239 let spans = highlighter.highlight_occurrences(&buffer, 1, 0, buffer.len());
241 assert_eq!(spans.len(), 3); }
243
244 #[test]
245 fn test_no_partial_matches() {
246 let buffer = make_buffer("foobar foo barfoo");
247 let highlighter = TextReferenceHighlighter::new();
248
249 let spans = highlighter.highlight_occurrences(&buffer, 8, 0, buffer.len());
251 assert_eq!(spans.len(), 1); }
253
254 #[test]
255 fn test_minimum_word_length() {
256 let buffer = make_buffer("a a a a");
257 let highlighter = TextReferenceHighlighter::new();
258
259 let spans = highlighter.highlight_occurrences(&buffer, 0, 0, buffer.len());
261 assert_eq!(spans.len(), 0);
262 }
263
264 #[test]
265 fn test_disabled_highlighting() {
266 let buffer = make_buffer("foo foo foo");
267 let mut highlighter = TextReferenceHighlighter::new();
268 highlighter.enabled = false;
269
270 let spans = highlighter.highlight_occurrences(&buffer, 0, 0, buffer.len());
271 assert_eq!(spans.len(), 0);
272 }
273}