vscode_theme_syntect/
lib.rs

1//! Parse a Visual Studio Code theme into a Syntect [Theme].
2//!
3//! # Usage
4//!
5//! ```rust
6//! use syntect::highlighting::Theme;
7//! use vscode_theme_syntect::VscodeTheme;
8//! use std::str::FromStr;
9//!
10//! let theme = VscodeTheme::from_str(include_str!("../assets/palenight.json")).expect("Failed to parse theme");
11//!
12//! assert!(Theme::try_from(theme).is_ok());
13//! ```
14//!
15//! Alternatively, you can parse themes using the included utility functions:
16//!
17//! - [`parse_vscode_theme_file`](parse_vscode_theme_file)
18//! - [`parse_vscode_theme`](parse_vscode_theme)
19
20use log::debug;
21use serde::Deserialize;
22use std::{collections::HashMap, path::Path, str::FromStr};
23use syntect::highlighting::{
24    Color, FontStyle, ScopeSelectors, StyleModifier, Theme, ThemeItem, ThemeSettings,
25};
26
27pub mod error;
28mod named_color;
29
30use crate::error::ParseError;
31
32/// A token color
33#[derive(Debug, Deserialize)]
34pub struct TokenColor {
35    pub scope: Option<Scope>,
36    pub settings: TokenSettings,
37}
38
39/// A scope of a token
40#[derive(Debug, Deserialize)]
41#[serde(untagged)]
42pub enum Scope {
43    Single(String),
44    Multiple(Vec<String>),
45}
46
47/// The settings of a token
48#[derive(Debug, Deserialize)]
49pub struct TokenSettings {
50    pub foreground: Option<String>,
51    pub background: Option<String>,
52    pub font_style: Option<String>,
53}
54
55/// A Visual Studio Code theme
56#[derive(Debug, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct VscodeTheme {
59    pub name: Option<String>,
60    pub author: Option<String>,
61    pub maintainers: Option<Vec<String>>,
62    pub type_: Option<String>,
63    pub colors: HashMap<String, Option<String>>,
64    pub token_colors: Vec<TokenColor>,
65}
66
67impl FromStr for VscodeTheme {
68    type Err = ParseError;
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        // NOTE: Need to remove comments and remove leading commas since JSON doesn't support them.
71        // TODO: Possibly do this manually to get rid of jsonc_parser.
72        let value: serde_json::Value = jsonc_parser::parse_to_ast(
73            s,
74            &jsonc_parser::CollectOptions {
75                comments: jsonc_parser::CommentCollectionStrategy::Off,
76                tokens: false,
77            },
78            &jsonc_parser::ParseOptions::default(),
79        )?
80        .value
81        .into();
82
83        serde_json::from_value(value).map_err(ParseError::Json)
84    }
85}
86
87fn get_color(s: &str) -> Result<Color, ParseError> {
88    debug!("get_color: {}", s);
89    if let Some(color) = named_color::from_name(s) {
90        Ok(color)
91    } else {
92        Ok(Color::from_str(s)?)
93    }
94}
95
96impl TryFrom<VscodeTheme> for Theme {
97    type Error = ParseError;
98    fn try_from(value: VscodeTheme) -> Result<Self, Self::Error> {
99        let mut settings = ThemeSettings::default();
100
101        for (key, value) in &value.colors {
102            if value.is_none() {
103                continue;
104            }
105
106            let value = value.as_ref().unwrap();
107            match &key[..] {
108                "editor.background" => settings.background = get_color(value).ok(),
109                "editor.foreground" => {
110                    settings.foreground = get_color(value).ok();
111                    settings.caret = settings.foreground;
112                }
113                "foreground" => settings.foreground = get_color(value).ok(),
114                "editorCursor.background" => settings.caret = get_color(value).ok(),
115                "editor.lineHighlightBackground" => settings.line_highlight = get_color(value).ok(),
116                "editorEditor.foreground" => settings.misspelling = get_color(value).ok(),
117                "list.highlightForeground" => {
118                    settings.find_highlight_foreground = get_color(value).ok();
119                    settings.accent = get_color(value).ok()
120                }
121                "editorGutter.background" => settings.gutter = get_color(value).ok(),
122                "editorLineNumber.foreground" => settings.gutter_foreground = get_color(value).ok(),
123                "editor.selectionBackground" => settings.selection = get_color(value).ok(),
124                "list.inactiveSelectionBackground" => {
125                    settings.inactive_selection = get_color(value).ok()
126                }
127                "list.inactiveSelectionForeground" => {
128                    settings.inactive_selection_foreground = get_color(value).ok()
129                }
130                "editor.findMatchBackground" | "peekViewEditor.matchHighlightBorder" => {
131                    settings.highlight = get_color(value).ok();
132                    settings.find_highlight = get_color(value).ok();
133                }
134                "editorIndentGuide.background" => settings.guide = get_color(value).ok(),
135                "breadcrumb.activeSelectionForeground" => {
136                    settings.active_guide = get_color(value).ok()
137                }
138                "breadcrumb.foreground" => settings.stack_guide = get_color(value).ok(),
139                "selection.background" => {
140                    settings.tags_foreground = get_color(value).ok();
141                    settings.brackets_foreground = get_color(value).ok();
142                }
143                "widget.shadow" | "scrollbar.shadow" => settings.shadow = get_color(value).ok(),
144                _ => (),
145            }
146        }
147
148        Ok(Self {
149            name: value.name,
150            author: value.author,
151            scopes: value
152                .token_colors
153                .iter()
154                .map(|color| {
155                    Ok(ThemeItem {
156                        scope: if let Some(scope) = &color.scope {
157                            match scope {
158                                Scope::Single(s) => ScopeSelectors::from_str(s)?,
159                                Scope::Multiple(s) => ScopeSelectors::from_str(&s.join(","))?,
160                            }
161                        } else {
162                            ScopeSelectors::from_str("*")?
163                        },
164                        style: StyleModifier {
165                            foreground: color
166                                .settings
167                                .foreground
168                                .clone()
169                                .and_then(|s| get_color(&s).ok()),
170                            background: color
171                                .settings
172                                .background
173                                .clone()
174                                .and_then(|s| get_color(&s).ok()),
175                            font_style: color
176                                .settings
177                                .font_style
178                                .clone()
179                                .map(|s| FontStyle::from_str(&s))
180                                .transpose()?,
181                        },
182                    })
183                })
184                .collect::<Result<Vec<_>, ParseError>>()?,
185            settings,
186        })
187    }
188}
189
190/// Parse a Visual Studio Code theme from a string.
191///
192/// Equivalent to calling [VscodeTheme::from_str]
193///
194/// # Example usage
195/// ```rust
196///
197/// use syntect::highlighting::Theme;
198/// use vscode_theme_syntect::parse_vscode_theme;
199///
200/// let theme = parse_vscode_theme(include_str!("../assets/palenight.json")).expect("Failed to parse theme");
201///
202/// assert!(Theme::try_from(theme).is_ok());
203/// ```
204pub fn parse_vscode_theme(scheme: &str) -> Result<VscodeTheme, ParseError> {
205    VscodeTheme::from_str(scheme)
206}
207
208/// Parse a Visual Studio Code theme from a file.
209///
210/// Equivalent to calling [parse_vscode_theme]
211///
212/// # Usage
213///
214/// ```rust
215///
216/// use std::path::Path;
217/// use syntect::highlighting::Theme;
218/// use vscode_theme_syntect::parse_vscode_theme_file;
219/// let vscode_theme = parse_vscode_theme_file(Path::new("assets/palenight.json")).unwrap();
220///
221/// assert!(Theme::try_from(vscode_theme).is_ok());
222///
223/// ```
224pub fn parse_vscode_theme_file(path: &Path) -> Result<VscodeTheme, ParseError> {
225    let scheme = std::fs::read_to_string(path)?;
226    parse_vscode_theme(&scheme)
227}
228
229#[cfg(test)]
230mod tests {
231    use log::debug;
232
233    use super::*;
234
235    fn start_log() {
236        let _ = env_logger::builder().is_test(true).try_init();
237    }
238
239    #[test]
240    fn convert_theme() {
241        start_log();
242        let schemes = vec![
243            (
244                "Synthwave",
245                include_str!("../assets/synthwave-color-theme.json"),
246            ),
247            (
248                "Tokyo Night",
249                include_str!("../assets/tokyo-night-color-theme.json"),
250            ),
251            ("Pale Night", include_str!("../assets/palenight.json")),
252            ("One Dark", include_str!("../assets/OneDark.json")),
253        ];
254
255        for (name, scheme) in schemes {
256            let now = std::time::Instant::now();
257            let scheme = VscodeTheme::from_str(scheme).expect("Failed to parse theme");
258
259            debug!("Parsed {} in {} ms", name, now.elapsed().as_millis());
260            Theme::try_from(scheme).expect("Failed to convert to theme");
261
262            debug!(
263                "Converted {} to SytectTheme in {} ms",
264                name,
265                now.elapsed().as_millis()
266            );
267        }
268    }
269}