Skip to main content

saorsa_core/
highlight.rs

1//! Pluggable syntax highlighting trait and default implementations.
2//!
3//! Provides a [`Highlighter`] trait that can be implemented to add
4//! syntax highlighting to [`crate::widget::TextArea`]. Includes a
5//! [`NoHighlighter`] (no-op) and a [`SimpleKeywordHighlighter`] for
6//! testing purposes.
7
8use crate::style::Style;
9
10/// A styled span within a single line of text.
11///
12/// Represents a range of characters `[start_col, end_col)` that
13/// should be rendered with the given style.
14#[derive(Clone, Debug, PartialEq)]
15pub struct HighlightSpan {
16    /// Start column (inclusive, zero-based character offset).
17    pub start_col: usize,
18    /// End column (exclusive, zero-based character offset).
19    pub end_col: usize,
20    /// The style to apply to this span.
21    pub style: Style,
22}
23
24/// Trait for providing syntax highlighting to text buffers.
25///
26/// Implementors return styled spans for each line. The
27/// [`NoHighlighter`] returns no spans (plain text). A tree-sitter
28/// highlighter can be plugged in later without changing the widget API.
29pub trait Highlighter {
30    /// Return highlight spans for a given line.
31    ///
32    /// `line_idx` is the zero-based line index in the buffer.
33    /// `text` is the content of that line (without trailing newline).
34    fn highlight_line(&self, line_idx: usize, text: &str) -> Vec<HighlightSpan>;
35
36    /// Notification that a line has been edited.
37    ///
38    /// Incremental parsers (e.g. tree-sitter) can use this to
39    /// invalidate cached parse results for the affected region.
40    fn on_edit(&mut self, line_idx: usize);
41}
42
43/// A no-op highlighter that returns no spans for any line.
44///
45/// This is the default highlighter used when no syntax highlighting
46/// is configured.
47#[derive(Clone, Debug, Default)]
48pub struct NoHighlighter;
49
50impl Highlighter for NoHighlighter {
51    fn highlight_line(&self, _line_idx: usize, _text: &str) -> Vec<HighlightSpan> {
52        Vec::new()
53    }
54
55    fn on_edit(&mut self, _line_idx: usize) {}
56}
57
58/// A simple keyword-based highlighter for testing.
59///
60/// Highlights exact keyword matches in each line. Keywords are
61/// matched as substrings — no word boundary detection is performed.
62#[derive(Clone, Debug)]
63pub struct SimpleKeywordHighlighter {
64    keywords: Vec<(String, Style)>,
65}
66
67impl SimpleKeywordHighlighter {
68    /// Create a new keyword highlighter with the given keyword-style pairs.
69    pub fn new(keywords: Vec<(String, Style)>) -> Self {
70        Self { keywords }
71    }
72}
73
74impl Highlighter for SimpleKeywordHighlighter {
75    fn highlight_line(&self, _line_idx: usize, text: &str) -> Vec<HighlightSpan> {
76        let mut spans = Vec::new();
77
78        for (keyword, style) in &self.keywords {
79            let mut search_start = 0;
80            while let Some(byte_idx) = text[search_start..].find(keyword.as_str()) {
81                let abs_byte_idx = search_start + byte_idx;
82                // Convert byte indices to character indices
83                let start_col = text[..abs_byte_idx].chars().count();
84                let end_col = start_col + keyword.chars().count();
85                spans.push(HighlightSpan {
86                    start_col,
87                    end_col,
88                    style: style.clone(),
89                });
90                search_start = abs_byte_idx + keyword.len();
91            }
92        }
93
94        // Sort by start position for consistent ordering
95        spans.sort_by_key(|s| s.start_col);
96        spans
97    }
98
99    fn on_edit(&mut self, _line_idx: usize) {
100        // No caching to invalidate in this simple implementation
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn no_highlighter_returns_empty() {
110        let h = NoHighlighter;
111        let spans = h.highlight_line(0, "hello world");
112        assert!(spans.is_empty());
113    }
114
115    #[test]
116    fn keyword_highlighter_finds_keyword() {
117        let h = SimpleKeywordHighlighter::new(vec![("fn".to_string(), Style::new().bold(true))]);
118        let spans = h.highlight_line(0, "fn main() {}");
119        assert!(spans.len() == 1);
120        assert!(spans[0].start_col == 0);
121        assert!(spans[0].end_col == 2);
122        assert!(spans[0].style.bold);
123    }
124
125    #[test]
126    fn multiple_keywords_same_line() {
127        let h = SimpleKeywordHighlighter::new(vec![
128            ("let".to_string(), Style::new().bold(true)),
129            ("mut".to_string(), Style::new().italic(true)),
130        ]);
131        let spans = h.highlight_line(0, "let mut x = 5;");
132        assert!(spans.len() == 2);
133        // "let" at col 0-3, "mut" at col 4-7
134        assert!(spans[0].start_col == 0);
135        assert!(spans[0].end_col == 3);
136        assert!(spans[1].start_col == 4);
137        assert!(spans[1].end_col == 7);
138    }
139
140    #[test]
141    fn no_match_returns_empty() {
142        let h = SimpleKeywordHighlighter::new(vec![("class".to_string(), Style::new().bold(true))]);
143        let spans = h.highlight_line(0, "fn main() {}");
144        assert!(spans.is_empty());
145    }
146
147    #[test]
148    fn partial_match_not_highlighted() {
149        // "fn" is NOT a substring of "function" (f-u-n vs f-n)
150        let h = SimpleKeywordHighlighter::new(vec![("fn".to_string(), Style::new().bold(true))]);
151        let spans = h.highlight_line(0, "function");
152        assert!(spans.is_empty());
153    }
154
155    #[test]
156    fn unicode_keyword_matching() {
157        let h = SimpleKeywordHighlighter::new(vec![("日本".to_string(), Style::new().bold(true))]);
158        let spans = h.highlight_line(0, "hello 日本語 world");
159        assert!(spans.len() == 1);
160        assert!(spans[0].start_col == 6);
161        assert!(spans[0].end_col == 8);
162    }
163
164    #[test]
165    fn multiple_occurrences_of_keyword() {
166        let h = SimpleKeywordHighlighter::new(vec![("ab".to_string(), Style::new().bold(true))]);
167        let spans = h.highlight_line(0, "ab cd ab");
168        assert!(spans.len() == 2);
169        assert!(spans[0].start_col == 0);
170        assert!(spans[1].start_col == 6);
171    }
172
173    #[test]
174    fn on_edit_no_panic() {
175        let mut h = NoHighlighter;
176        h.on_edit(0);
177        let mut kh =
178            SimpleKeywordHighlighter::new(vec![("x".to_string(), Style::new().bold(true))]);
179        kh.on_edit(5);
180    }
181}