syntax_theme.rs

  1#![allow(missing_docs)]
  2
  3use std::{
  4    collections::{BTreeMap, btree_map::Entry},
  5    sync::Arc,
  6};
  7
  8use gpui::HighlightStyle;
  9#[cfg(any(test, feature = "test-support"))]
 10use gpui::Hsla;
 11
 12#[derive(Debug, PartialEq, Eq, Clone, Default)]
 13pub struct SyntaxTheme {
 14    highlights: Vec<HighlightStyle>,
 15    capture_name_map: BTreeMap<String, usize>,
 16}
 17
 18impl SyntaxTheme {
 19    pub fn new(highlights: impl IntoIterator<Item = (String, HighlightStyle)>) -> Self {
 20        let (capture_names, highlights) = highlights.into_iter().unzip();
 21
 22        Self {
 23            capture_name_map: Self::create_capture_name_map(capture_names),
 24            highlights,
 25        }
 26    }
 27
 28    fn create_capture_name_map(highlights: Vec<String>) -> BTreeMap<String, usize> {
 29        highlights
 30            .into_iter()
 31            .enumerate()
 32            .map(|(i, key)| (key, i))
 33            .collect()
 34    }
 35
 36    #[cfg(any(test, feature = "test-support"))]
 37    pub fn new_test(colors: impl IntoIterator<Item = (&'static str, Hsla)>) -> Self {
 38        Self::new_test_styles(colors.into_iter().map(|(key, color)| {
 39            (
 40                key,
 41                HighlightStyle {
 42                    color: Some(color),
 43                    ..Default::default()
 44                },
 45            )
 46        }))
 47    }
 48
 49    #[cfg(any(test, feature = "test-support"))]
 50    pub fn new_test_styles(
 51        colors: impl IntoIterator<Item = (&'static str, HighlightStyle)>,
 52    ) -> Self {
 53        Self::new(
 54            colors
 55                .into_iter()
 56                .map(|(key, style)| (key.to_owned(), style)),
 57        )
 58    }
 59
 60    pub fn get(&self, highlight_index: impl Into<usize>) -> Option<&HighlightStyle> {
 61        self.highlights.get(highlight_index.into())
 62    }
 63
 64    pub fn style_for_name(&self, name: &str) -> Option<HighlightStyle> {
 65        self.capture_name_map
 66            .get(name)
 67            .map(|highlight_idx| self.highlights[*highlight_idx])
 68    }
 69
 70    pub fn get_capture_name(&self, idx: impl Into<usize>) -> Option<&str> {
 71        let idx = idx.into();
 72        self.capture_name_map
 73            .iter()
 74            .find(|(_, value)| **value == idx)
 75            .map(|(key, _)| key.as_ref())
 76    }
 77
 78    pub fn highlight_id(&self, capture_name: &str) -> Option<u32> {
 79        self.capture_name_map
 80            .range::<str, _>((
 81                capture_name.split(".").next().map_or(
 82                    std::ops::Bound::Included(capture_name),
 83                    std::ops::Bound::Included,
 84                ),
 85                std::ops::Bound::Included(capture_name),
 86            ))
 87            .rfind(|(prefix, _)| {
 88                capture_name
 89                    .strip_prefix(*prefix)
 90                    .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.'))
 91            })
 92            .map(|(_, index)| *index as u32)
 93    }
 94
 95    /// Returns a new [`Arc<SyntaxTheme>`] with the given syntax styles merged in.
 96    pub fn merge(base: Arc<Self>, user_syntax_styles: Vec<(String, HighlightStyle)>) -> Arc<Self> {
 97        if user_syntax_styles.is_empty() {
 98            return base;
 99        }
100
101        let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone());
102
103        for (name, highlight) in user_syntax_styles {
104            match base.capture_name_map.entry(name) {
105                Entry::Occupied(entry) => {
106                    if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) {
107                        existing_highlight.color = highlight.color.or(existing_highlight.color);
108                        existing_highlight.font_weight =
109                            highlight.font_weight.or(existing_highlight.font_weight);
110                        existing_highlight.font_style =
111                            highlight.font_style.or(existing_highlight.font_style);
112                        existing_highlight.background_color = highlight
113                            .background_color
114                            .or(existing_highlight.background_color);
115                        existing_highlight.underline =
116                            highlight.underline.or(existing_highlight.underline);
117                        existing_highlight.strikethrough =
118                            highlight.strikethrough.or(existing_highlight.strikethrough);
119                        existing_highlight.fade_out =
120                            highlight.fade_out.or(existing_highlight.fade_out);
121                    }
122                }
123                Entry::Vacant(vacant) => {
124                    vacant.insert(base.highlights.len());
125                    base.highlights.push(highlight);
126                }
127            }
128        }
129
130        Arc::new(base)
131    }
132}
133
134#[cfg(feature = "bundled-themes")]
135mod bundled_themes {
136    use std::collections::BTreeMap;
137    use std::sync::Arc;
138
139    use gpui::{FontStyle, FontWeight, HighlightStyle, Hsla, Rgba, rgb};
140    use serde::Deserialize;
141
142    use super::SyntaxTheme;
143
144    #[derive(Deserialize)]
145    struct ThemeFile {
146        themes: Vec<ThemeEntry>,
147    }
148
149    #[derive(Deserialize)]
150    struct ThemeEntry {
151        name: String,
152        style: ThemeStyle,
153    }
154
155    #[derive(Deserialize)]
156    struct ThemeStyle {
157        syntax: BTreeMap<String, SyntaxStyleEntry>,
158    }
159
160    #[derive(Deserialize)]
161    struct SyntaxStyleEntry {
162        color: Option<String>,
163        font_weight: Option<f32>,
164        font_style: Option<String>,
165    }
166
167    impl SyntaxStyleEntry {
168        fn to_highlight_style(&self) -> HighlightStyle {
169            HighlightStyle {
170                color: self.color.as_deref().map(hex_to_hsla),
171                font_weight: self.font_weight.map(FontWeight),
172                font_style: self.font_style.as_deref().and_then(|s| match s {
173                    "italic" => Some(FontStyle::Italic),
174                    "normal" => Some(FontStyle::Normal),
175                    "oblique" => Some(FontStyle::Oblique),
176                    _ => None,
177                }),
178                ..Default::default()
179            }
180        }
181    }
182
183    fn hex_to_hsla(hex: &str) -> Hsla {
184        let hex = hex.trim_start_matches('#');
185        let rgba: Rgba = match hex.len() {
186            6 => rgb(u32::from_str_radix(hex, 16).unwrap_or(0)),
187            8 => {
188                let value = u32::from_str_radix(hex, 16).unwrap_or(0);
189                Rgba {
190                    r: ((value >> 24) & 0xff) as f32 / 255.0,
191                    g: ((value >> 16) & 0xff) as f32 / 255.0,
192                    b: ((value >> 8) & 0xff) as f32 / 255.0,
193                    a: (value & 0xff) as f32 / 255.0,
194                }
195            }
196            _ => rgb(0),
197        };
198        rgba.into()
199    }
200
201    fn load_theme(json: &str, theme_name: &str) -> Arc<SyntaxTheme> {
202        let theme_file: ThemeFile = serde_json::from_str(json).expect("failed to parse theme JSON");
203        let theme_entry = theme_file
204            .themes
205            .iter()
206            .find(|entry| entry.name == theme_name)
207            .unwrap_or_else(|| panic!("theme {theme_name:?} not found in theme JSON"));
208
209        let highlights = theme_entry
210            .style
211            .syntax
212            .iter()
213            .map(|(name, entry)| (name.clone(), entry.to_highlight_style()));
214
215        Arc::new(SyntaxTheme::new(highlights))
216    }
217
218    impl SyntaxTheme {
219        /// Load the "One Dark" syntax theme from the bundled theme JSON.
220        pub fn one_dark() -> Arc<Self> {
221            load_theme(
222                include_str!("../../../assets/themes/one/one.json"),
223                "One Dark",
224            )
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use gpui::FontStyle;
232
233    use super::*;
234
235    #[test]
236    fn test_syntax_theme_merge() {
237        // Merging into an empty `SyntaxTheme` keeps all the user-defined styles.
238        let syntax_theme = SyntaxTheme::merge(
239            Arc::new(SyntaxTheme::new_test([])),
240            vec![
241                (
242                    "foo".to_string(),
243                    HighlightStyle {
244                        color: Some(gpui::red()),
245                        ..Default::default()
246                    },
247                ),
248                (
249                    "foo.bar".to_string(),
250                    HighlightStyle {
251                        color: Some(gpui::green()),
252                        ..Default::default()
253                    },
254                ),
255            ],
256        );
257        assert_eq!(
258            syntax_theme,
259            Arc::new(SyntaxTheme::new_test([
260                ("foo", gpui::red()),
261                ("foo.bar", gpui::green())
262            ]))
263        );
264
265        // Merging empty user-defined styles keeps all the base styles.
266        let syntax_theme = SyntaxTheme::merge(
267            Arc::new(SyntaxTheme::new_test([
268                ("foo", gpui::blue()),
269                ("foo.bar", gpui::red()),
270            ])),
271            Vec::new(),
272        );
273        assert_eq!(
274            syntax_theme,
275            Arc::new(SyntaxTheme::new_test([
276                ("foo", gpui::blue()),
277                ("foo.bar", gpui::red())
278            ]))
279        );
280
281        let syntax_theme = SyntaxTheme::merge(
282            Arc::new(SyntaxTheme::new_test([
283                ("foo", gpui::red()),
284                ("foo.bar", gpui::green()),
285            ])),
286            vec![(
287                "foo.bar".to_string(),
288                HighlightStyle {
289                    color: Some(gpui::yellow()),
290                    ..Default::default()
291                },
292            )],
293        );
294        assert_eq!(
295            syntax_theme,
296            Arc::new(SyntaxTheme::new_test([
297                ("foo", gpui::red()),
298                ("foo.bar", gpui::yellow())
299            ]))
300        );
301
302        let syntax_theme = SyntaxTheme::merge(
303            Arc::new(SyntaxTheme::new_test([
304                ("foo", gpui::red()),
305                ("foo.bar", gpui::green()),
306            ])),
307            vec![(
308                "foo.bar".to_string(),
309                HighlightStyle {
310                    font_style: Some(FontStyle::Italic),
311                    ..Default::default()
312                },
313            )],
314        );
315        assert_eq!(
316            syntax_theme,
317            Arc::new(SyntaxTheme::new_test_styles([
318                (
319                    "foo",
320                    HighlightStyle {
321                        color: Some(gpui::red()),
322                        ..Default::default()
323                    }
324                ),
325                (
326                    "foo.bar",
327                    HighlightStyle {
328                        color: Some(gpui::green()),
329                        font_style: Some(FontStyle::Italic),
330                        ..Default::default()
331                    }
332                )
333            ]))
334        );
335    }
336}