Skip to main content

fresh/primitives/
reference_highlight_text.rs

1//! Text-based reference highlighting (WASM-compatible)
2//!
3//! When the cursor is on a word, all occurrences of that word in the viewport
4//! are highlighted. This module provides simple whole-word text matching
5//! without requiring tree-sitter.
6//!
7//! # Design
8//!
9//! - Pure text matching (no AST analysis)
10//! - Efficient viewport-based search
11//! - Respects word boundaries (won't match partial words)
12
13use 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
19/// Default highlight color for word occurrences
20pub const DEFAULT_HIGHLIGHT_COLOR: Color = Color::Rgb(60, 60, 80);
21
22/// Text-based reference highlighter (WASM-compatible)
23///
24/// Highlights all occurrences of the word under cursor using simple
25/// text matching. This is the fallback mode when tree-sitter is not available.
26pub struct TextReferenceHighlighter {
27    /// Color for occurrence highlights
28    pub highlight_color: Color,
29    /// Minimum word length to trigger highlighting
30    pub min_word_length: usize,
31    /// Whether highlighting is enabled
32    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    /// Create a new text reference highlighter
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Create with custom highlight color
52    pub fn with_color(color: Color) -> Self {
53        Self {
54            highlight_color: color,
55            ..Self::default()
56        }
57    }
58
59    /// Highlight occurrences of word under cursor
60    ///
61    /// Returns highlight spans for all whole-word matches of the word
62    /// at `cursor_position` within the viewport range.
63    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        // Find the word under the cursor
75        let word_range = match self.get_word_at_position(buffer, cursor_position) {
76            Some(range) => range,
77            None => return Vec::new(),
78        };
79
80        // Get the word text
81        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        // Check minimum length
88        if word.len() < self.min_word_length {
89            return Vec::new();
90        }
91
92        // Find all occurrences in the viewport
93        let occurrences =
94            self.find_occurrences_in_range(buffer, &word, viewport_start, viewport_end);
95
96        // Convert to highlight spans
97        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    /// Get the word range at the given position
109    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        // Check if cursor is on a word character
116        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            // Cursor at end of buffer - check previous character
124            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            // Check if we're just after a word at end of buffer
135            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        // Find word boundaries
156        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    /// Maximum search range (1MB) to avoid performance issues
167    const MAX_SEARCH_RANGE: usize = 1024 * 1024;
168
169    /// Find all whole-word occurrences in a byte range
170    fn find_occurrences_in_range(
171        &self,
172        buffer: &Buffer,
173        word: &str,
174        start: usize,
175        end: usize,
176    ) -> Vec<Range<usize>> {
177        // Skip if search range is too large
178        if end.saturating_sub(start) > Self::MAX_SEARCH_RANGE {
179            return Vec::new();
180        }
181
182        let mut occurrences = Vec::new();
183
184        // Get the text with padding for edge words
185        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        // Use match_indices for efficient single-pass searching
195        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            // Check if this is a whole word match
200            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                // Only include if within viewport
212                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        // Cursor on first "foo"
241        let spans = highlighter.highlight_occurrences(&buffer, 1, 0, buffer.len());
242        assert_eq!(spans.len(), 3); // Three occurrences of "foo"
243    }
244
245    #[test]
246    fn test_no_partial_matches() {
247        let buffer = make_buffer("foobar foo barfoo");
248        let highlighter = TextReferenceHighlighter::new();
249
250        // Cursor on standalone "foo"
251        let spans = highlighter.highlight_occurrences(&buffer, 8, 0, buffer.len());
252        assert_eq!(spans.len(), 1); // Only the standalone "foo", not "foobar" or "barfoo"
253    }
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        // Single-character word should not be highlighted (min_word_length = 2)
261        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}