ghostscope_ui/components/command_panel/
syntax_highlighter.rs

1use ratatui::{
2    style::{Color, Style},
3    text::Span,
4};
5use regex::Regex;
6
7/// Syntax highlighting for GhostScope script language
8pub struct SyntaxHighlighter {
9    // Compiled regex patterns for efficient matching
10    keywords_regex: Regex,
11    string_regex: Regex,
12    comment_regex: Regex,
13    number_regex: Regex,
14    hex_address_regex: Regex,
15    special_var_regex: Regex,
16    operator_regex: Regex,
17}
18
19impl SyntaxHighlighter {
20    pub fn new() -> Self {
21        Self {
22            // Keywords: trace, print, backtrace, bt, if, else, let
23            keywords_regex: Regex::new(r"\b(trace|print|backtrace|bt|if|else|let)\b").unwrap(),
24
25            // String literals: "..."
26            string_regex: Regex::new(r#""([^"\\]|\\.)*""#).unwrap(),
27
28            // Comments: // ... or /* ... */
29            comment_regex: Regex::new(r"(//.*)|(/\*[\s\S]*?\*/)").unwrap(),
30
31            // Numbers: integers and floats
32            number_regex: Regex::new(r"\b\d+(\.\d+)?\b").unwrap(),
33
34            // Hex addresses: 0x...
35            hex_address_regex: Regex::new(r"\b0x[0-9a-fA-F]+\b").unwrap(),
36
37            // Special variables: $variable
38            special_var_regex: Regex::new(r"\$[a-zA-Z_][a-zA-Z0-9_]*").unwrap(),
39
40            // Operators: +, -, *, /, ==, !=, <=, >=, <, >
41            operator_regex: Regex::new(r"(==|!=|<=|>=|[+\-*/=<>])").unwrap(),
42        }
43    }
44
45    /// Highlight a line of script code and return colored spans
46    pub fn highlight_line(&self, line: &str) -> Vec<Span<'static>> {
47        let mut spans = Vec::new();
48        let mut last_end = 0;
49
50        // Collect all matches with their positions and types
51        let mut matches = Vec::new();
52
53        // Find all pattern matches
54        self.collect_matches(line, &mut matches);
55
56        // Sort matches by position to process them in order
57        matches.sort_by_key(|m| m.start);
58
59        // Remove overlapping matches (prefer earlier ones)
60        let mut filtered_matches = Vec::new();
61        let mut last_match_end = 0;
62
63        for m in matches {
64            if m.start >= last_match_end {
65                filtered_matches.push(m.clone());
66                last_match_end = m.end;
67            }
68        }
69
70        // Generate spans
71        for m in filtered_matches {
72            // Add unstyled text before this match
73            if m.start > last_end {
74                let text = &line[last_end..m.start];
75                if !text.is_empty() {
76                    spans.push(Span::styled(
77                        text.to_string(),
78                        Style::default().fg(Color::White),
79                    ));
80                }
81            }
82
83            // Add styled match
84            let text = &line[m.start..m.end];
85            spans.push(Span::styled(text.to_string(), m.style));
86
87            last_end = m.end;
88        }
89
90        // Add remaining unstyled text
91        if last_end < line.len() {
92            let text = &line[last_end..];
93            spans.push(Span::styled(
94                text.to_string(),
95                Style::default().fg(Color::White),
96            ));
97        }
98
99        // If no matches found, return the whole line as default styled
100        if spans.is_empty() {
101            spans.push(Span::styled(
102                line.to_string(),
103                Style::default().fg(Color::White),
104            ));
105        }
106
107        spans
108    }
109
110    /// Collect all regex matches with their positions and styles
111    fn collect_matches(&self, line: &str, matches: &mut Vec<Match>) {
112        // Comments have highest priority to avoid highlighting inside them
113        for m in self.comment_regex.find_iter(line) {
114            matches.push(Match {
115                start: m.start(),
116                end: m.end(),
117                style: Style::default().fg(Color::DarkGray),
118            });
119        }
120
121        // Strings have high priority
122        for m in self.string_regex.find_iter(line) {
123            matches.push(Match {
124                start: m.start(),
125                end: m.end(),
126                style: Style::default().fg(Color::Green),
127            });
128        }
129
130        // Hex addresses (should come before general numbers)
131        for m in self.hex_address_regex.find_iter(line) {
132            matches.push(Match {
133                start: m.start(),
134                end: m.end(),
135                style: Style::default().fg(Color::Magenta),
136            });
137        }
138
139        // Numbers
140        for m in self.number_regex.find_iter(line) {
141            matches.push(Match {
142                start: m.start(),
143                end: m.end(),
144                style: Style::default().fg(Color::Magenta),
145            });
146        }
147
148        // Keywords
149        for m in self.keywords_regex.find_iter(line) {
150            matches.push(Match {
151                start: m.start(),
152                end: m.end(),
153                style: Style::default().fg(Color::Blue),
154            });
155        }
156
157        // Special variables
158        for m in self.special_var_regex.find_iter(line) {
159            matches.push(Match {
160                start: m.start(),
161                end: m.end(),
162                style: Style::default().fg(Color::Cyan),
163            });
164        }
165
166        // Operators
167        for m in self.operator_regex.find_iter(line) {
168            matches.push(Match {
169                start: m.start(),
170                end: m.end(),
171                style: Style::default().fg(Color::Yellow),
172            });
173        }
174    }
175}
176
177impl Default for SyntaxHighlighter {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// A match found in the text with position and style information
184#[derive(Clone)]
185struct Match {
186    start: usize,
187    end: usize,
188    style: Style,
189}
190
191/// Convenience function to highlight a single line
192pub fn highlight_line(line: &str) -> Vec<Span<'static>> {
193    // Use a static highlighter instance for efficiency
194    use std::sync::OnceLock;
195    static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
196
197    let highlighter = HIGHLIGHTER.get_or_init(SyntaxHighlighter::new);
198    highlighter.highlight_line(line)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_keyword_highlighting() {
207        let highlighter = SyntaxHighlighter::new();
208        let spans = highlighter.highlight_line("trace calculate_something {");
209
210        // Should have at least one blue span for "trace"
211        assert!(spans.iter().any(|span| span.style.fg == Some(Color::Blue)));
212    }
213
214    #[test]
215    fn test_string_highlighting() {
216        let highlighter = SyntaxHighlighter::new();
217        let spans = highlighter.highlight_line(r#"print "Hello, world!";"#);
218
219        // Should have a green span for the string
220        assert!(spans.iter().any(|span| span.style.fg == Some(Color::Green)));
221        // Should have a blue span for "print"
222        assert!(spans.iter().any(|span| span.style.fg == Some(Color::Blue)));
223    }
224
225    #[test]
226    fn test_comment_highlighting() {
227        let highlighter = SyntaxHighlighter::new();
228        let spans = highlighter.highlight_line("// This is a comment");
229
230        // Should have a dark gray span for the comment
231        assert!(spans
232            .iter()
233            .any(|span| span.style.fg == Some(Color::DarkGray)));
234    }
235
236    #[test]
237    fn test_number_highlighting() {
238        let highlighter = SyntaxHighlighter::new();
239        let spans = highlighter.highlight_line("let x = 42;");
240
241        // Should have a magenta span for the number
242        assert!(spans
243            .iter()
244            .any(|span| span.style.fg == Some(Color::Magenta)));
245    }
246
247    #[test]
248    fn test_hex_address_highlighting() {
249        let highlighter = SyntaxHighlighter::new();
250        let spans = highlighter.highlight_line("trace 0x1234abcd {");
251
252        // Should have a magenta span for the hex address
253        assert!(spans
254            .iter()
255            .any(|span| span.style.fg == Some(Color::Magenta)));
256    }
257
258    #[test]
259    fn test_special_variable_highlighting() {
260        let highlighter = SyntaxHighlighter::new();
261        let spans = highlighter.highlight_line("print $pid;");
262
263        // Should have a cyan span for the special variable
264        assert!(spans.iter().any(|span| span.style.fg == Some(Color::Cyan)));
265    }
266
267    #[test]
268    fn test_operator_highlighting() {
269        let highlighter = SyntaxHighlighter::new();
270        let spans = highlighter.highlight_line("if a == b {");
271
272        // Should have a yellow span for the operator
273        assert!(spans
274            .iter()
275            .any(|span| span.style.fg == Some(Color::Yellow)));
276    }
277
278    #[test]
279    fn test_complex_line() {
280        let highlighter = SyntaxHighlighter::new();
281        let spans = highlighter
282            .highlight_line(r#"print "Value: {} at 0x{:x}", value, 0x1000; // Debug output"#);
283
284        // Should have multiple different colored spans
285        let colors: std::collections::HashSet<_> =
286            spans.iter().filter_map(|span| span.style.fg).collect();
287
288        // Expect at least: blue (print), green (string), magenta (hex), dark gray (comment)
289        assert!(colors.len() >= 3);
290    }
291}