syntastica_core/
theme.rs

1//! Defines items related to theming the output.
2
3use std::{
4    borrow::{Borrow, Cow},
5    collections::BTreeMap,
6    ops::Index,
7    str::FromStr,
8};
9
10use crate::{
11    style::{Color, Style},
12    Error, Result,
13};
14
15/// All theme keys that are recognized by syntastica.
16///
17/// A [`Theme`] or [`ResolvedTheme`] should define styles for any subset of these.
18///
19/// <details>
20/// <summary>View the full list</summary>
21///
22/// ```ignore
23#[doc = include_str!("./theme_keys.rs")]
24/// ```
25///
26/// </details>
27///
28/// The list is based on the list from
29/// [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter/blob/master/CONTRIBUTING.md).
30pub const THEME_KEYS: &[&str] = &include!("./theme_keys.rs");
31
32/// A raw theme which may contain links to other items inside.
33///
34/// Internally, this type stores a map from [`String`]s to [`ThemeValue`]s. This map can be
35/// retrieved using [`Theme::into_inner`]. The map keys do _not_ all have to be in [`THEME_KEYS`];
36/// other custom keys can be used, for example to define a set of colors and reuse them with links
37/// everywhere else.
38///
39/// When using the <span class="stab portability"><code>serde</code></span> feature, this type
40/// implements serde's `Serialize` and `Deserialize` traits.
41///
42/// # Instantiation
43///
44/// The easiest way to create a [`Theme`] is with the [`theme!`](crate::theme!) macro.
45/// Alternatively, a [`Theme`] may be created from a [`BTreeMap<String, ThemeValue>`] using
46/// [`Theme::new`].
47#[derive(Clone, Hash, Debug, PartialEq, Eq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(transparent))]
50pub struct Theme(BTreeMap<String, ThemeValue>);
51
52/// A [`Theme`] where all internal links have been resolved.
53///
54/// Instead of [`ThemeValue`]s, a [`ResolvedTheme`] has [`Style`]s as values. These cannot link to
55/// other entries of the theme but completely define a style on their own.
56///
57/// A [`ResolvedTheme`] can be created from a [`Theme`] with [`Theme::resolve_links`] or the
58/// [`TryFrom<Theme>`](#impl-TryFrom<Theme>-for-ResolvedTheme) implementation.
59///
60/// To get the style for a key in this theme, the preferred method is using
61/// [`ResolvedTheme::find_style`], which will return the best match it finds. See the method's docs
62/// for more information. Alternatively, [`ResolvedTheme::get`] can be used to only look for an
63/// exact match.
64// TODO: better support for background color
65#[derive(Clone, Hash, Debug, PartialEq, Eq)]
66pub struct ResolvedTheme(BTreeMap<Cow<'static, str>, Style>);
67
68/// A value of a [`Theme`] containing style information and/or a link to another key in the
69/// [`Theme`].
70///
71/// When using the <span class="stab portability"><code>serde</code></span> feature, this type
72/// implements serde's `Serialize` and `Deserialize` traits using the untagged enum representation.
73#[derive(Clone, Hash, Debug, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(feature = "serde", serde(untagged))]
76pub enum ThemeValue {
77    /// May either be a hexadecimal color literal, or a `$` followed by the name of another
78    /// theme key.
79    ///
80    /// In the latter case, this value links to the [`ThemeValue`] which the [`Theme`] specifies
81    /// for the provided theme key.
82    Simple(String),
83    /// A color or link with additional style information.
84    Extended {
85        /// The foreground color to use for this style, specified as a hexadecimal string.
86        ///
87        /// Either this or [`link`](ThemeValue::Extended::link) has to be set, or calls to
88        /// [`Theme::resolve_links`] will fail.
89        color: Option<String>,
90
91        /// The background color to use for this style, specified as a hexadecimal string.
92        bg: Option<String>,
93
94        /// Whether the text should be underlined. (default is `false`)
95        #[cfg_attr(feature = "serde", serde(default))]
96        underline: bool,
97
98        /// Whether the text should be strikethrough. (default is `false`)
99        #[cfg_attr(feature = "serde", serde(default))]
100        strikethrough: bool,
101
102        /// Whether the text should be italic. (default is `false`)
103        #[cfg_attr(feature = "serde", serde(default))]
104        italic: bool,
105
106        /// Whether the text should be bold. (default is `false`)
107        #[cfg_attr(feature = "serde", serde(default))]
108        bold: bool,
109
110        /// A link to the theme entry with the given key.
111        ///
112        /// Either this or [`color`](ThemeValue::Extended::color) has to be set, or calls to
113        /// [`Theme::resolve_links`] will fail.
114        link: Option<String>,
115    },
116}
117
118impl Theme {
119    /// Create a new [`Theme`] from a map of [theme keys](THEME_KEYS) to [`ThemeValue`]s.
120    pub fn new(highlights: BTreeMap<String, ThemeValue>) -> Self {
121        Self(highlights)
122    }
123
124    /// Consume `self` and return the contained theme map.
125    ///
126    /// May be used to merge multiple [`Theme`]s together.
127    pub fn into_inner(self) -> BTreeMap<String, ThemeValue> {
128        self.0
129    }
130
131    /// Try to resolve all links in this [`Theme`] and return a [`ResolvedTheme`].
132    ///
133    /// # Errors
134    ///
135    /// The function may return the following errors:
136    ///
137    /// - [`Error::InvalidHex`] if a color string was an invalid hexadecimal literal.
138    /// - [`Error::InvalidLink`] if a link points to a non-existent key.
139    pub fn resolve_links(mut self) -> Result<ResolvedTheme> {
140        self.resolve_links_impl()?;
141        Ok(ResolvedTheme::new(
142            self.0
143                .into_iter()
144                .map(|(key, value)| {
145                    Ok((
146                        key.into(),
147                        match value {
148                            ThemeValue::Simple(color) => Style::new(
149                                Color::from_str(&color)?,
150                                None,
151                                false,
152                                false,
153                                false,
154                                false,
155                            ),
156                            ThemeValue::Extended {
157                                color,
158                                bg,
159                                underline,
160                                strikethrough,
161                                italic,
162                                bold,
163                                link: _,
164                            } => Style::new(
165                                // TODO: maybe rework to not rely on unwrapping
166                                Color::from_str(&color.expect("links have been resolved"))?,
167                                bg.map(|color| Color::from_str(&color)).transpose()?,
168                                underline,
169                                strikethrough,
170                                italic,
171                                bold,
172                            ),
173                        },
174                    ))
175                })
176                .collect::<Result<_>>()?,
177        ))
178    }
179
180    fn resolve_links_impl(&mut self) -> Result<()> {
181        let mut must_reresolve = false;
182        let mut replacements = vec![];
183        for (key, value) in self.0.iter() {
184            let link_key = match value {
185                ThemeValue::Simple(str) if str.starts_with('$') => &str[1..],
186                ThemeValue::Extended {
187                    link: Some(str), ..
188                } => str,
189                _ => continue,
190            };
191            let resolved = value.resolve_link(
192                self.0
193                    .get(link_key)
194                    .ok_or_else(|| Error::InvalidLink(link_key.to_owned()))?,
195            );
196            if matches!(&resolved, ThemeValue::Simple(str) if str.starts_with('$'))
197                || matches!(&resolved, ThemeValue::Extended { link: Some(_), .. })
198            {
199                must_reresolve = true;
200            }
201            replacements.push((key.clone(), resolved));
202        }
203        for (key, replacement) in replacements {
204            *self.0.get_mut(&key).expect("key validity checked above") = replacement;
205        }
206        if must_reresolve {
207            self.resolve_links_impl()?;
208        }
209        Ok(())
210    }
211}
212
213impl From<BTreeMap<String, ThemeValue>> for Theme {
214    fn from(highlights: BTreeMap<String, ThemeValue>) -> Self {
215        Self::new(highlights)
216    }
217}
218
219impl ResolvedTheme {
220    /// Create a new [`ResolvedTheme`] from a map of [theme keys](THEME_KEYS) to [`Style`]s.
221    pub fn new(highlights: BTreeMap<Cow<'static, str>, Style>) -> Self {
222        Self(highlights)
223    }
224
225    /// Consume `self` and return the contained theme map.
226    ///
227    /// May be used to merge multiple [`ResolvedTheme`]s together.
228    pub fn into_inner(self) -> BTreeMap<Cow<'static, str>, Style> {
229        self.0
230    }
231
232    /// Returns a reference to the style corresponding to the key.
233    pub fn get<Q>(&self, key: &Q) -> Option<&Style>
234    where
235        Cow<'static, str>: Borrow<Q>,
236        Q: Ord + ?Sized,
237    {
238        self.0.get(key)
239    }
240
241    /// Get the default foreground color, if the theme defines one.
242    pub fn fg(&self) -> Option<Color> {
243        self.get("_normal").map(|style| style.color())
244    }
245
246    /// Get the default background color, if the theme defines one.
247    pub fn bg(&self) -> Option<Color> {
248        self.get("_normal").and_then(|style| style.bg())
249    }
250
251    /// Try to find the best possible style supported by the given a theme key.
252    ///
253    /// For example, if `key` is `keyword.operator` but this theme only has a style defined for
254    /// `keyword`, then the style for `keyword` is returned. Additionally, if no style is found,
255    /// the method tries to use the theme's [default foreground](ResolvedTheme::fg) as a fallback.
256    pub fn find_style(&self, mut key: &str) -> Option<Style> {
257        // if the theme contains the entire key, use that
258        if let Some(style) = self.get(key) {
259            return Some(*style);
260        }
261
262        // otherwise continue to strip the right-most part of the key
263        while let Some((rest, _)) = key.rsplit_once('.') {
264            // until the theme contains the key
265            if let Some(style) = self.get(rest) {
266                return Some(*style);
267            }
268            key = rest;
269        }
270
271        // or when the theme doesn't have any matching style, try to use the foreground
272        self.fg().map(Style::from)
273    }
274}
275
276impl<Q> Index<&Q> for ResolvedTheme
277where
278    Cow<'static, str>: Borrow<Q>,
279    Q: Ord + ?Sized,
280{
281    type Output = Style;
282
283    fn index(&self, key: &Q) -> &Self::Output {
284        self.get(key).expect("no entry found for key")
285    }
286}
287
288impl From<BTreeMap<Cow<'static, str>, Style>> for ResolvedTheme {
289    fn from(highlights: BTreeMap<Cow<'static, str>, Style>) -> Self {
290        Self::new(highlights)
291    }
292}
293
294impl TryFrom<Theme> for ResolvedTheme {
295    type Error = Error;
296
297    /// Try to create a [`ResolvedTheme`] from a [`Theme`] by calling [`Theme::resolve_links`].
298    fn try_from(value: Theme) -> Result<Self> {
299        value.resolve_links()
300    }
301}
302
303impl ThemeValue {
304    fn resolve_link(&self, target: &Self) -> Self {
305        match (self, target) {
306            (ThemeValue::Simple(_), _) => target.clone(),
307            (
308                ThemeValue::Extended {
309                    color: Some(color),
310                    bg,
311                    underline,
312                    strikethrough,
313                    italic,
314                    bold,
315                    link: _,
316                },
317                ThemeValue::Simple(_),
318            )
319            | (
320                ThemeValue::Extended {
321                    color: None,
322                    bg,
323                    underline,
324                    strikethrough,
325                    italic,
326                    bold,
327                    link: _,
328                },
329                ThemeValue::Simple(color),
330            ) => Self::Extended {
331                color: Some(color.clone()),
332                bg: bg.clone(),
333                underline: *underline,
334                strikethrough: *strikethrough,
335                italic: *italic,
336                bold: *bold,
337                link: None,
338            },
339            (
340                ThemeValue::Extended {
341                    color: color @ Some(_),
342                    bg,
343                    underline,
344                    strikethrough,
345                    italic,
346                    bold,
347                    link: _,
348                },
349                ThemeValue::Extended {
350                    color: _,
351                    bg: other_bg,
352                    underline: other_underline,
353                    strikethrough: other_strikethrough,
354                    italic: other_italic,
355                    bold: other_bold,
356                    link,
357                },
358            )
359            | (
360                ThemeValue::Extended {
361                    color: None,
362                    bg,
363                    underline,
364                    strikethrough,
365                    italic,
366                    bold,
367                    link: _,
368                },
369                ThemeValue::Extended {
370                    color,
371                    bg: other_bg,
372                    underline: other_underline,
373                    strikethrough: other_strikethrough,
374                    italic: other_italic,
375                    bold: other_bold,
376                    link,
377                },
378            ) => Self::Extended {
379                color: color.clone(),
380                bg: bg.clone().or_else(|| other_bg.clone()),
381                underline: *underline || *other_underline,
382                strikethrough: *strikethrough || *other_strikethrough,
383                italic: *italic || *other_italic,
384                bold: *bold || *other_bold,
385                link: link.clone(),
386            },
387        }
388    }
389}
390
391/// Convenience macro for constructing new [`Theme`]s.
392///
393/// Currently, the macro is very strict about the input's structure. See the [example](#example)
394/// below to learn more. Also note that for [extended values](ThemeValue::Extended), either
395/// [`color`](ThemeValue::Extended::color) or [`link`](ThemeValue::Extended::link) must be set, or
396/// any call to [`resolve_links`](Theme::resolve_links) will fail.
397///
398/// See the documentation for [`Theme`] and [`ResolvedTheme`] for more information on themes.
399///
400/// # Example
401///
402/// ```
403/// use std::collections::BTreeMap;
404/// use syntastica_core::{
405///     theme,
406///     theme::{Theme, ThemeValue},
407/// };
408///
409/// let theme = theme! {
410///     // specify colors using hex literals
411///     "purple": "#c678dd",
412///     "blue": "#61afef",
413///     "green": "#98c379",
414///
415///     // link to other keys using a `$` sign
416///     "keyword": "$purple",
417///     "function": "$blue",
418///
419///     // specify more styling options in curly braces
420///     // (note that currently this order required by the macro)
421///     "string": {
422///         color: None, // either `None` or `"#<color>"`
423///         bg: None, // either `None` or `"#<color>"`
424///         underline: false,
425///         strikethrough: false,
426///         italic: true,
427///         bold: false,
428///         link: "green", // either `None` or `"<key>"`
429///     },
430/// };
431///
432/// # #[rustfmt::skip]
433/// assert_eq!(theme, Theme::new(BTreeMap::from([
434///     ("purple".to_owned(), ThemeValue::Simple("#c678dd".to_owned())),
435///     ("blue".to_owned(), ThemeValue::Simple("#61afef".to_owned())),
436///     ("green".to_owned(), ThemeValue::Simple("#98c379".to_owned())),
437///     ("keyword".to_owned(), ThemeValue::Simple("$purple".to_owned())),
438///     ("function".to_owned(), ThemeValue::Simple("$blue".to_owned())),
439///     ("string".to_owned(), ThemeValue::Extended {
440///         color: None,
441///         bg: None,
442///         underline: false,
443///         strikethrough: false,
444///         italic: true,
445///         bold: false,
446///         link: Some("green".to_owned()),
447///     }),
448/// ])));
449/// ```
450#[macro_export(local_inner_macros)]
451macro_rules! theme {
452    ($($tt:tt)*) => {
453        theme_impl!($($tt)*)
454    };
455}
456
457#[macro_export(local_inner_macros)]
458#[doc(hidden)]
459macro_rules! theme_impl {
460    () => {};
461    ($($key:literal : $value:tt),* $(,)?) => {{
462        let mut theme = ::std::collections::BTreeMap::new();
463        $(
464            theme.insert($key.to_owned(), theme_impl!(@value $value));
465        )*
466        $crate::theme::Theme::new(theme)
467    }};
468    (@value $str:literal) => {
469        $crate::theme::ThemeValue::Simple($str.to_owned())
470    };
471    (@value {
472        color: $color:tt,
473        bg: $bg:tt,
474        underline: $underline:expr,
475        strikethrough: $strikethrough:expr,
476        italic: $italic:expr,
477        bold: $bold:expr,
478        link: $link:tt $(,)?
479    }) => {
480        $crate::theme::ThemeValue::Extended {
481            color: theme_impl!(@option $color),
482            bg: theme_impl!(@option $bg),
483            underline: $underline,
484            strikethrough: $strikethrough,
485            italic: $italic,
486            bold: $bold,
487            link: theme_impl!(@option $link),
488        }
489    };
490    (@option None) => { None };
491    (@option $str:literal) => { Some($str.to_owned()) };
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn style_finding() {
500        let theme = theme! {
501            "keyword": "#000000",
502            "keyword.return": "#ff0000",
503        }
504        .resolve_links()
505        .unwrap();
506
507        assert_eq!(
508            theme.find_style("keyword.return"),
509            Some(Style::color_only(255, 0, 0)),
510        );
511        assert_eq!(
512            theme.find_style("keyword.operator"),
513            Some(Style::color_only(0, 0, 0)),
514        );
515        assert_eq!(
516            theme.find_style("keyword"),
517            Some(Style::color_only(0, 0, 0)),
518        );
519        assert_eq!(theme.find_style("other"), None);
520    }
521
522    #[test]
523    fn style_fallback() {
524        let theme = theme! {
525            "_normal": "#000000",
526        }
527        .resolve_links()
528        .unwrap();
529
530        assert_eq!(theme.find_style("other"), Some(Style::color_only(0, 0, 0)));
531    }
532}