Skip to main content

tui_markdown/
code_theme.rs

1//! Syntax-highlighting themes for fenced code blocks.
2//!
3//! [`CodeTheme`] represents bundled themes converted from [`BuiltinCodeTheme`] and TextMate themes
4//! parsed with [`CodeTheme::from_textmate`] or loaded with [`CodeTheme::from_file`]. Select any of
5//! them with [`Options::code_theme`](crate::Options::code_theme). The renderer consults the theme
6//! when a fenced code block names a recognized language.
7//!
8//! A configured [`CodeTheme`] owns its theme data. [`Options`](crate::Options) stores no theme by
9//! default; the renderer instead borrows a shared [`CodeTheme`] for
10//! [`BuiltinCodeTheme::Base16OceanDark`] when it first encounters a recognized fenced language.
11
12use std::error::Error;
13use std::fmt;
14use std::io::Cursor;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use syntect::highlighting::{Theme, ThemeSet};
19
20/// An owned syntax-highlighting theme for fenced code blocks.
21///
22/// Convert a [`BuiltinCodeTheme`] into this type, parse TextMate source with
23/// [`CodeTheme::from_textmate`], or load a TextMate file with [`CodeTheme::from_file`]. Then pass
24/// the theme to [`Options::code_theme`](crate::Options::code_theme).
25///
26/// The renderer consults the theme only for fenced code blocks whose language is recognized.
27///
28/// This type hides the syntax-highlighting implementation so applications do not need to depend on
29/// its types or version.
30#[derive(Clone, Debug)]
31pub struct CodeTheme {
32    theme: Theme,
33}
34
35impl CodeTheme {
36    /// Parses a TextMate syntax-highlighting theme.
37    ///
38    /// This constructor parses `source` immediately and does not access the filesystem. The
39    /// returned theme owns the parsed data and does not borrow `source`. Applications can combine
40    /// this method with [`include_str!`] to compile a theme into their binary.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`CodeThemeLoadError`] when `source` is not a valid TextMate theme.
45    pub fn from_textmate(source: &str) -> Result<Self, CodeThemeLoadError> {
46        let mut reader = Cursor::new(source);
47        let theme = ThemeSet::load_from_reader(&mut reader)
48            .map_err(|source| CodeThemeLoadError { path: None, source })?;
49        Ok(Self { theme })
50    }
51
52    /// Loads a TextMate syntax-highlighting theme from disk.
53    ///
54    /// This function reads and parses the file synchronously. The resulting `CodeTheme` owns the
55    /// parsed theme, so rendering does not access the file again. Use [`CodeTheme::from_textmate`]
56    /// with [`include_str!`] when the theme should be compiled into the application instead.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`CodeThemeLoadError`] when the file cannot be read or its contents are not a valid
61    /// TextMate theme. The error message includes the requested path.
62    ///
63    /// # Example
64    ///
65    /// ```no_run
66    /// use tui_markdown::{CodeTheme, Options};
67    ///
68    /// let theme = CodeTheme::from_file("themes/solarized.tmTheme")?;
69    /// let options = Options::default().code_theme(theme);
70    /// # Ok::<(), tui_markdown::CodeThemeLoadError>(())
71    /// ```
72    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, CodeThemeLoadError> {
73        let path = path.as_ref();
74        let theme = ThemeSet::get_theme(path).map_err(|source| CodeThemeLoadError {
75            path: Some(path.to_owned()),
76            source,
77        })?;
78        Ok(Self { theme })
79    }
80}
81
82/// A syntax-highlighting theme bundled with tui-markdown.
83///
84/// Pass a variant directly to [`Options::code_theme`](crate::Options::code_theme), or convert it
85/// into an owned [`CodeTheme`].
86#[non_exhaustive]
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum BuiltinCodeTheme {
89    /// The dark Base16 Eighties theme.
90    Base16EightiesDark,
91    /// The dark Base16 Mocha theme.
92    Base16MochaDark,
93    /// The default dark Base16 Ocean theme.
94    #[default]
95    Base16OceanDark,
96    /// The light Base16 Ocean theme.
97    Base16OceanLight,
98    /// The light Inspired GitHub theme.
99    InspiredGitHub,
100    /// The dark Solarized theme.
101    SolarizedDark,
102    /// The light Solarized theme.
103    SolarizedLight,
104}
105
106impl BuiltinCodeTheme {
107    fn syntect_name(self) -> &'static str {
108        match self {
109            Self::Base16EightiesDark => "base16-eighties.dark",
110            Self::Base16MochaDark => "base16-mocha.dark",
111            Self::Base16OceanDark => "base16-ocean.dark",
112            Self::Base16OceanLight => "base16-ocean.light",
113            Self::InspiredGitHub => "InspiredGitHub",
114            Self::SolarizedDark => "Solarized (dark)",
115            Self::SolarizedLight => "Solarized (light)",
116        }
117    }
118}
119
120impl From<BuiltinCodeTheme> for CodeTheme {
121    fn from(theme: BuiltinCodeTheme) -> Self {
122        let theme = builtin_theme(theme).clone();
123        Self { theme }
124    }
125}
126
127/// An error returned when a syntax-highlighting theme cannot be parsed or loaded.
128///
129/// Errors from [`CodeTheme::from_file`] include the requested path. Errors from
130/// [`CodeTheme::from_textmate`] identify invalid TextMate source. [`Error::source`] provides the
131/// underlying parsing error without making the parser part of tui-markdown's public API.
132#[non_exhaustive]
133#[derive(Debug)]
134pub struct CodeThemeLoadError {
135    path: Option<PathBuf>,
136    source: syntect::LoadingError,
137}
138
139impl fmt::Display for CodeThemeLoadError {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        if let Some(path) = &self.path {
142            write!(
143                formatter,
144                "failed to load code theme from `{}`: {}",
145                path.display(),
146                self.source
147            )
148        } else {
149            write!(
150                formatter,
151                "failed to parse TextMate code theme: {}",
152                self.source
153            )
154        }
155    }
156}
157
158impl Error for CodeThemeLoadError {
159    fn source(&self) -> Option<&(dyn Error + 'static)> {
160        Some(&self.source)
161    }
162}
163
164/// Returns the syntax-highlighting data for a code theme.
165pub fn theme(code_theme: &CodeTheme) -> &Theme {
166    &code_theme.theme
167}
168
169/// Returns the lazily initialized default code theme.
170///
171/// The renderer calls this only after recognizing a fenced language, so ordinary Markdown and
172/// unrecognized code fences do not initialize the bundled theme set.
173pub fn default() -> &'static CodeTheme {
174    &DEFAULT_THEME
175}
176
177fn builtin_theme(code_theme: BuiltinCodeTheme) -> &'static Theme {
178    THEMES
179        .themes
180        .get(code_theme.syntect_name())
181        .expect("every BuiltinCodeTheme variant must map to a bundled theme")
182}
183
184static DEFAULT_THEME: LazyLock<CodeTheme> = LazyLock::new(|| BuiltinCodeTheme::default().into());
185static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
186
187#[cfg(test)]
188mod tests {
189    use std::path::PathBuf;
190
191    use indoc::indoc;
192    use ratatui_core::style::Color;
193
194    use crate::{from_str_with_options, Options};
195
196    use super::*;
197
198    fn fixture(name: &str) -> PathBuf {
199        Path::new(env!("CARGO_MANIFEST_DIR"))
200            .join("src/code_theme/fixtures")
201            .join(name)
202    }
203
204    #[test]
205    fn every_builtin_theme_can_be_selected() {
206        let themes = [
207            BuiltinCodeTheme::Base16EightiesDark,
208            BuiltinCodeTheme::Base16MochaDark,
209            BuiltinCodeTheme::Base16OceanDark,
210            BuiltinCodeTheme::Base16OceanLight,
211            BuiltinCodeTheme::InspiredGitHub,
212            BuiltinCodeTheme::SolarizedDark,
213            BuiltinCodeTheme::SolarizedLight,
214        ];
215
216        for built_in in themes {
217            let code_theme = CodeTheme::from(built_in);
218            let _ = theme(&code_theme);
219        }
220    }
221
222    #[test]
223    fn configured_theme_is_borrowed_directly() {
224        let code_theme = CodeTheme::from(BuiltinCodeTheme::SolarizedDark);
225
226        assert!(std::ptr::eq(theme(&code_theme), &code_theme.theme));
227    }
228
229    #[test]
230    fn default_theme_is_shared() {
231        assert!(std::ptr::eq(default(), default()));
232    }
233
234    #[test]
235    fn loaded_theme_applies_its_foreground_color() {
236        let theme = CodeTheme::from_file(fixture("custom.tmTheme")).unwrap();
237
238        assert_eq!(
239            rendered_keyword_foreground(theme),
240            Some(Color::Rgb(255, 255, 255))
241        );
242    }
243
244    #[test]
245    fn embedded_theme_applies_its_foreground_color() {
246        let source = include_str!("code_theme/fixtures/custom.tmTheme");
247        let theme = CodeTheme::from_textmate(source).unwrap();
248
249        assert_eq!(
250            rendered_keyword_foreground(theme),
251            Some(Color::Rgb(255, 255, 255))
252        );
253    }
254
255    fn rendered_keyword_foreground(theme: CodeTheme) -> Option<Color> {
256        let input = indoc! {"
257            ```rust
258            fn main() {}
259            ```
260        "};
261        let options = Options::default().code_theme(theme);
262        let rendered = from_str_with_options(input, &options);
263        rendered
264            .lines
265            .iter()
266            .flat_map(|line| &line.spans)
267            .find(|span| span.content == "fn")
268            .expect("Rust highlighting should emit the `fn` keyword")
269            .style
270            .fg
271    }
272
273    #[test]
274    fn missing_theme_reports_its_path_and_read_error() {
275        let path = fixture("missing.tmTheme");
276        let error = CodeTheme::from_file(&path).unwrap_err();
277
278        let prefix = format!("failed to load code theme from `{}`:", path.display());
279        assert!(error.to_string().starts_with(&prefix));
280        assert!(matches!(&error.source, syntect::LoadingError::Io(_)));
281        assert!(error.source().is_some());
282    }
283
284    #[test]
285    fn malformed_theme_reports_its_path_and_parse_error() {
286        let path = fixture("invalid.tmTheme");
287        let error = CodeTheme::from_file(&path).unwrap_err();
288
289        let prefix = format!("failed to load code theme from `{}`:", path.display());
290        assert!(error.to_string().starts_with(&prefix));
291        assert!(matches!(
292            &error.source,
293            syntect::LoadingError::ReadSettings(_)
294        ));
295        assert!(error.source().is_some());
296    }
297
298    #[test]
299    fn malformed_embedded_theme_reports_a_parse_error() {
300        let error = CodeTheme::from_textmate("this is not a TextMate theme").unwrap_err();
301
302        assert!(error
303            .to_string()
304            .starts_with("failed to parse TextMate code theme:"));
305        assert!(matches!(
306            &error.source,
307            syntect::LoadingError::ReadSettings(_)
308        ));
309        assert!(error.source().is_some());
310    }
311}