git_iris/studio/components/
syntax.rs1use 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
12static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
14 std::sync::LazyLock::new(SyntaxSet::load_defaults_newlines);
15
16static THEME_SET: std::sync::LazyLock<ThemeSet> = std::sync::LazyLock::new(ThemeSet::load_defaults);
18
19pub struct SyntaxHighlighter {
21 syntax: Option<&'static SyntaxReference>,
22}
23
24impl SyntaxHighlighter {
25 #[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 #[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 #[must_use]
41 pub fn is_available(&self) -> bool {
42 self.syntax.is_some()
43 }
44
45 #[must_use]
47 pub fn highlight_line(&self, line: &str) -> Vec<(Style, String)> {
48 let Some(syntax) = self.syntax else {
49 return vec![(
51 Style::default().fg(theme::text_primary_color()),
52 line.to_string(),
53 )];
54 };
55
56 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 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 #[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
91fn 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
109fn syntect_color_to_silkcircuit(color: syntect::highlighting::Color) -> Color {
112 let r = color.r;
114 let g = color.g;
115 let b = color.b;
116
117 let saturation = color_saturation(r, g, b);
122 let luminance = color_luminance(r, g, b);
123
124 if is_purple_ish(r, g, b) {
126 return theme::accent_primary();
127 }
128
129 if is_green_ish(r, g, b) && saturation > 0.3 {
131 return theme::success_color();
132 }
133
134 if is_orange_ish(r, g, b) {
136 return theme::accent_tertiary();
137 }
138
139 if is_cyan_ish(r, g, b) {
141 return theme::accent_secondary();
142 }
143
144 if is_yellow_ish(r, g, b) {
146 return theme::warning_color();
147 }
148
149 if saturation < 0.15 && luminance < 0.6 {
151 return theme::text_muted_color();
152 }
153
154 if luminance > 0.2 {
156 Color::Rgb(r, g, b)
157 } else {
158 theme::text_secondary_color()
159 }
160}
161
162fn 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 r > 150 && g < 150 && b > 150
177}
178
179fn is_green_ish(r: u8, g: u8, b: u8) -> bool {
180 g > r && g > b && g > 120
182}
183
184fn is_orange_ish(r: u8, g: u8, b: u8) -> bool {
185 r > 180 && g > 80 && g < 180 && b < 150
187}
188
189fn is_cyan_ish(r: u8, g: u8, b: u8) -> bool {
190 r < 150 && g > 150 && b > 150
192}
193
194fn is_yellow_ish(r: u8, g: u8, b: u8) -> bool {
195 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}