Skip to main content

git_iris/studio/components/
syntax.rs

1//! Syntax highlighting for code view using `SilkCircuit` colors
2//!
3//! Maps syntect token types to our theme colors for a cohesive look.
4
5use ratatui::style::{Color, Modifier, Style};
6use syntect::easy::HighlightLines;
7use syntect::highlighting::{FontStyle, Style as SyntectStyle, ThemeSet};
8use syntect::parsing::{SyntaxReference, SyntaxSet};
9
10use crate::studio::theme;
11
12/// Global syntax set - loaded once
13static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
14    std::sync::LazyLock::new(SyntaxSet::load_defaults_newlines);
15
16/// Global theme set - load default themes for syntax highlighting
17static THEME_SET: std::sync::LazyLock<ThemeSet> = std::sync::LazyLock::new(ThemeSet::load_defaults);
18
19/// Syntax highlighter with caching
20pub struct SyntaxHighlighter {
21    syntax: Option<&'static SyntaxReference>,
22}
23
24impl SyntaxHighlighter {
25    /// Create a new highlighter for the given file extension
26    #[must_use]
27    pub fn for_extension(ext: &str) -> Self {
28        let syntax = SYNTAX_SET.find_syntax_by_extension(ext);
29        Self { syntax }
30    }
31
32    /// Create a new highlighter for the given file path
33    #[must_use]
34    pub fn for_path(path: &std::path::Path) -> Self {
35        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
36        Self::for_extension(ext)
37    }
38
39    /// Check if syntax highlighting is available
40    #[must_use]
41    pub fn is_available(&self) -> bool {
42        self.syntax.is_some()
43    }
44
45    /// Highlight a single line, returning styled spans
46    #[must_use]
47    pub fn highlight_line(&self, line: &str) -> Vec<(Style, String)> {
48        let Some(syntax) = self.syntax else {
49            // No syntax highlighting - return plain
50            return vec![(
51                Style::default().fg(theme::text_primary_color()),
52                line.to_string(),
53            )];
54        };
55
56        // Try to get a dark theme, fallback to any available theme, or give up
57        let Some(theme) = THEME_SET
58            .themes
59            .get("base16-ocean.dark")
60            .or_else(|| THEME_SET.themes.get("InspiredGitHub"))
61            .or_else(|| THEME_SET.themes.values().next())
62        else {
63            // No themes available - return plain text
64            return vec![(
65                Style::default().fg(theme::text_primary_color()),
66                line.to_string(),
67            )];
68        };
69
70        let mut highlighter = HighlightLines::new(syntax, theme);
71
72        match highlighter.highlight_line(line, &SYNTAX_SET) {
73            Ok(ranges) => ranges
74                .into_iter()
75                .map(|(style, text)| (syntect_to_ratatui(style), text.to_string()))
76                .collect(),
77            Err(_) => vec![(
78                Style::default().fg(theme::text_primary_color()),
79                line.to_string(),
80            )],
81        }
82    }
83
84    /// Highlight multiple lines
85    #[must_use]
86    pub fn highlight_lines(&self, lines: &[String]) -> Vec<Vec<(Style, String)>> {
87        lines.iter().map(|line| self.highlight_line(line)).collect()
88    }
89}
90
91/// Convert syntect style to ratatui style with `SilkCircuit` color mapping
92fn syntect_to_ratatui(style: SyntectStyle) -> Style {
93    let fg = syntect_color_to_silkcircuit(style.foreground);
94    let mut ratatui_style = Style::default().fg(fg);
95
96    if style.font_style.contains(FontStyle::BOLD) {
97        ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
98    }
99    if style.font_style.contains(FontStyle::ITALIC) {
100        ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
101    }
102    if style.font_style.contains(FontStyle::UNDERLINE) {
103        ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
104    }
105
106    ratatui_style
107}
108
109/// Map syntect colors to `SilkCircuit` palette
110/// This creates a cohesive look by mapping token colors to our theme
111fn syntect_color_to_silkcircuit(color: syntect::highlighting::Color) -> Color {
112    // Extract RGB values
113    let r = color.r;
114    let g = color.g;
115    let b = color.b;
116
117    // Map common syntax highlighting colors to SilkCircuit palette
118    // We analyze the color characteristics and map to our theme
119
120    // Very bright/saturated colors -> map to our accent colors
121    let saturation = color_saturation(r, g, b);
122    let luminance = color_luminance(r, g, b);
123
124    // Keywords, control flow (often purple/magenta in themes)
125    if is_purple_ish(r, g, b) {
126        return theme::accent_primary();
127    }
128
129    // Strings (often green/teal)
130    if is_green_ish(r, g, b) && saturation > 0.3 {
131        return theme::success_color();
132    }
133
134    // Numbers, constants (often orange/coral)
135    if is_orange_ish(r, g, b) {
136        return theme::accent_tertiary();
137    }
138
139    // Functions, methods (often cyan/blue)
140    if is_cyan_ish(r, g, b) {
141        return theme::accent_secondary();
142    }
143
144    // Types, classes (often yellow)
145    if is_yellow_ish(r, g, b) {
146        return theme::warning_color();
147    }
148
149    // Comments (usually gray/dim)
150    if saturation < 0.15 && luminance < 0.6 {
151        return theme::text_muted_color();
152    }
153
154    // Default: use original color if it's reasonably visible
155    if luminance > 0.2 {
156        Color::Rgb(r, g, b)
157    } else {
158        theme::text_secondary_color()
159    }
160}
161
162// Color analysis helpers
163
164fn color_saturation(r: u8, g: u8, b: u8) -> f32 {
165    let max = f32::from(r.max(g).max(b));
166    let min = f32::from(r.min(g).min(b));
167    if max == 0.0 { 0.0 } else { (max - min) / max }
168}
169
170fn color_luminance(r: u8, g: u8, b: u8) -> f32 {
171    (0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b)) / 255.0
172}
173
174fn is_purple_ish(r: u8, g: u8, b: u8) -> bool {
175    // Purple: high red, low green, high blue
176    r > 150 && g < 150 && b > 150
177}
178
179fn is_green_ish(r: u8, g: u8, b: u8) -> bool {
180    // Green: low red, high green, variable blue
181    g > r && g > b && g > 120
182}
183
184fn is_orange_ish(r: u8, g: u8, b: u8) -> bool {
185    // Orange/coral: high red, medium green, low blue
186    r > 180 && g > 80 && g < 180 && b < 150
187}
188
189fn is_cyan_ish(r: u8, g: u8, b: u8) -> bool {
190    // Cyan: low red, high green, high blue
191    r < 150 && g > 150 && b > 150
192}
193
194fn is_yellow_ish(r: u8, g: u8, b: u8) -> bool {
195    // Yellow: high red, high green, low blue
196    r > 180 && g > 180 && b < 150
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_highlighter_rust() {
205        let highlighter = SyntaxHighlighter::for_extension("rs");
206        assert!(highlighter.is_available());
207
208        let spans = highlighter.highlight_line("fn main() { }");
209        assert!(!spans.is_empty());
210    }
211
212    #[test]
213    fn test_highlighter_unknown() {
214        let highlighter = SyntaxHighlighter::for_extension("xyz_unknown");
215        assert!(!highlighter.is_available());
216    }
217}