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}