main.rs

  1mod assets;
  2mod color;
  3mod theme_printer;
  4mod util;
  5mod vscode;
  6mod zed1;
  7
  8use std::collections::HashMap;
  9use std::fs::{self, File};
 10use std::io::Write;
 11use std::path::PathBuf;
 12use std::process::Command;
 13use std::str::FromStr;
 14
 15use any_ascii::any_ascii;
 16use anyhow::{anyhow, Context, Result};
 17use clap::Parser;
 18use convert_case::{Case, Casing};
 19use gpui::serde_json;
 20use indexmap::IndexMap;
 21use indoc::formatdoc;
 22use json_comments::StripComments;
 23use log::LevelFilter;
 24use serde::Deserialize;
 25use simplelog::{TermLogger, TerminalMode};
 26use theme::{Appearance, UserTheme, UserThemeFamily};
 27
 28use crate::theme_printer::UserThemeFamilyPrinter;
 29use crate::vscode::VsCodeTheme;
 30use crate::vscode::VsCodeThemeConverter;
 31use crate::zed1::theme::Theme as Zed1Theme;
 32use crate::zed1::{zed1_theme_licenses, Zed1ThemeConverter};
 33
 34#[derive(Debug, Deserialize)]
 35struct FamilyMetadata {
 36    pub name: String,
 37    pub author: String,
 38    pub themes: Vec<ThemeMetadata>,
 39
 40    /// Overrides for specific syntax tokens.
 41    ///
 42    /// Use this to ensure certain Zed syntax tokens are matched
 43    /// to an exact set of scopes when it is not otherwise possible
 44    /// to rely on the default mappings in the theme importer.
 45    #[serde(default)]
 46    pub syntax: IndexMap<String, Vec<String>>,
 47}
 48
 49#[derive(Debug, Clone, Copy, Deserialize)]
 50#[serde(rename_all = "snake_case")]
 51pub enum ThemeAppearanceJson {
 52    Light,
 53    Dark,
 54}
 55
 56impl From<ThemeAppearanceJson> for Appearance {
 57    fn from(value: ThemeAppearanceJson) -> Self {
 58        match value {
 59            ThemeAppearanceJson::Light => Self::Light,
 60            ThemeAppearanceJson::Dark => Self::Dark,
 61        }
 62    }
 63}
 64
 65#[derive(Debug, Deserialize)]
 66pub struct ThemeMetadata {
 67    pub name: String,
 68    pub file_name: String,
 69    pub appearance: ThemeAppearanceJson,
 70}
 71
 72#[derive(Parser)]
 73#[command(author, version, about, long_about = None)]
 74struct Args {
 75    /// Whether to warn when values are missing from the theme.
 76    #[arg(long)]
 77    warn_on_missing: bool,
 78}
 79
 80fn main() -> Result<()> {
 81    const SOURCE_PATH: &str = "assets/themes/src/vscode";
 82    const OUT_PATH: &str = "crates/theme/src/themes";
 83
 84    let args = Args::parse();
 85
 86    let log_config = {
 87        let mut config = simplelog::ConfigBuilder::new();
 88        config
 89            .set_level_color(log::Level::Trace, simplelog::Color::Cyan)
 90            .set_level_color(log::Level::Info, simplelog::Color::Blue)
 91            .set_level_color(log::Level::Warn, simplelog::Color::Yellow)
 92            .set_level_color(log::Level::Error, simplelog::Color::Red);
 93
 94        if !args.warn_on_missing {
 95            config.add_filter_ignore_str("theme_printer");
 96        }
 97
 98        config.build()
 99    };
100
101    TermLogger::init(LevelFilter::Trace, log_config, TerminalMode::Mixed)
102        .expect("could not initialize logger");
103
104    let mut theme_families = Vec::new();
105
106    /// Whether VS Code themes should be imported.
107    const IMPORT_VS_CODE_THEMES: bool = false;
108
109    if IMPORT_VS_CODE_THEMES {
110        log::info!("Loading themes source...");
111        let vscode_themes_path = PathBuf::from_str(SOURCE_PATH)?;
112        if !vscode_themes_path.exists() {
113            return Err(anyhow!(format!(
114                "Couldn't find {}, make sure it exists",
115                SOURCE_PATH
116            )));
117        }
118
119        for theme_family_dir in fs::read_dir(&vscode_themes_path)? {
120            let theme_family_dir = theme_family_dir?;
121
122            if !theme_family_dir.file_type()?.is_dir() {
123                continue;
124            }
125
126            let theme_family_slug = theme_family_dir
127                .path()
128                .file_stem()
129                .ok_or(anyhow!("no file stem"))
130                .map(|stem| stem.to_string_lossy().to_string())?;
131
132            let family_metadata_file = File::open(theme_family_dir.path().join("family.json"))
133                .context(format!(
134                    "no `family.json` found for '{}'",
135                    theme_family_slug
136                ))?;
137
138            let license_file_path = theme_family_dir.path().join("LICENSE");
139
140            if !license_file_path.exists() {
141                log::info!("Skipping theme family '{}' because it does not have a LICENSE file. This theme will only be imported once a LICENSE file is provided.", theme_family_slug);
142                continue;
143            }
144
145            let family_metadata: FamilyMetadata = serde_json::from_reader(family_metadata_file)
146                .context(format!(
147                    "failed to parse `family.json` for '{theme_family_slug}'"
148                ))?;
149
150            let mut themes = Vec::new();
151
152            for theme_metadata in family_metadata.themes {
153                log::info!("Converting '{}' theme", &theme_metadata.name);
154
155                let theme_file_path = theme_family_dir.path().join(&theme_metadata.file_name);
156
157                let theme_file = match File::open(&theme_file_path) {
158                    Ok(file) => file,
159                    Err(_) => {
160                        log::info!("Failed to open file at path: {:?}", theme_file_path);
161                        continue;
162                    }
163                };
164
165                let theme_without_comments = StripComments::new(theme_file);
166                let vscode_theme: VsCodeTheme = serde_json::from_reader(theme_without_comments)
167                    .context(format!("failed to parse theme {theme_file_path:?}"))?;
168
169                let converter = VsCodeThemeConverter::new(
170                    vscode_theme,
171                    theme_metadata,
172                    family_metadata.syntax.clone(),
173                );
174
175                let theme = converter.convert()?;
176
177                themes.push(theme);
178            }
179
180            let theme_family = UserThemeFamily {
181                name: family_metadata.name.into(),
182                author: family_metadata.author.into(),
183                themes,
184            };
185
186            theme_families.push(theme_family);
187        }
188    }
189
190    let zed1_themes_path = PathBuf::from_str("assets/themes")?;
191
192    let zed1_theme_familes = [
193        "Andromeda",
194        "Atelier",
195        "Ayu",
196        "Gruvbox",
197        "One",
198        "Rosé Pine",
199        "Sandcastle",
200        "Solarized",
201        "Summercamp",
202    ];
203
204    let zed1_licenses_by_theme: HashMap<String, zed1::Zed1ThemeLicense> = HashMap::from_iter(
205        zed1_theme_licenses()
206            .into_iter()
207            .map(|theme_license| (theme_license.theme.clone(), theme_license)),
208    );
209
210    let mut zed1_themes_by_family: IndexMap<String, Vec<UserTheme>> = IndexMap::from_iter(
211        zed1_theme_familes
212            .into_iter()
213            .map(|family| (family.to_string(), Vec::new())),
214    );
215
216    for entry in fs::read_dir(&zed1_themes_path)? {
217        let entry = entry?;
218
219        if entry.file_type()?.is_dir() {
220            continue;
221        }
222
223        match entry.path().extension() {
224            None => continue,
225            Some(extension) => {
226                if extension != "json" {
227                    continue;
228                }
229            }
230        }
231
232        let theme_file_path = entry.path();
233
234        let theme_file = match File::open(&theme_file_path) {
235            Ok(file) => file,
236            Err(_) => {
237                log::info!("Failed to open file at path: {:?}", theme_file_path);
238                continue;
239            }
240        };
241
242        let theme_without_comments = StripComments::new(theme_file);
243
244        let zed1_theme: Zed1Theme = serde_json::from_reader(theme_without_comments)
245            .context(format!("failed to parse theme {theme_file_path:?}"))?;
246
247        let theme_name = zed1_theme.meta.name.clone();
248
249        let converter = Zed1ThemeConverter::new(zed1_theme);
250
251        let theme = converter.convert()?;
252
253        let Some((_, themes_for_family)) = zed1_themes_by_family
254            .iter_mut()
255            .find(|(family, _)| theme_name.starts_with(*family))
256        else {
257            log::warn!("No theme family found for '{}'.", theme_name);
258            continue;
259        };
260
261        themes_for_family.push(theme);
262    }
263
264    zed1_themes_by_family.sort_keys();
265
266    let mut licenses = Vec::new();
267
268    for (family, themes) in zed1_themes_by_family {
269        let mut theme_family = UserThemeFamily {
270            name: family,
271            author: "Zed Industries".to_string(),
272            themes,
273        };
274
275        theme_family
276            .themes
277            .sort_unstable_by_key(|theme| theme.name.clone());
278
279        for theme in &theme_family.themes {
280            let license = zed1_licenses_by_theme
281                .get(&theme.name)
282                .ok_or_else(|| anyhow!("missing license for theme: '{}'", theme.name))?;
283
284            let license_header = match license.license_url.as_ref() {
285                Some(license_url) => {
286                    format!("[{theme_name}]({license_url})", theme_name = theme.name)
287                }
288                None => theme.name.clone(),
289            };
290
291            licenses.push(formatdoc!(
292                "
293                ## {license_header}
294
295                {license_text}
296                ********************************************************************************
297                ",
298                license_text = license.license_text
299            ));
300        }
301
302        theme_families.push(theme_family);
303    }
304
305    let themes_output_path = PathBuf::from_str(OUT_PATH)?;
306
307    if !themes_output_path.exists() {
308        log::info!("Creating directory: {:?}", themes_output_path);
309        fs::create_dir_all(&themes_output_path)?;
310    }
311
312    let mut mod_rs_file = File::create(themes_output_path.join(format!("mod.rs")))?;
313
314    let mut theme_modules = Vec::new();
315
316    for theme_family in theme_families {
317        let theme_family_slug = any_ascii(&theme_family.name)
318            .replace("(", "")
319            .replace(")", "")
320            .to_case(Case::Snake);
321
322        let mut output_file =
323            File::create(themes_output_path.join(format!("{theme_family_slug}.rs")))?;
324        log::info!(
325            "Creating file: {:?}",
326            themes_output_path.join(format!("{theme_family_slug}.rs"))
327        );
328
329        let theme_module = format!(
330            r#"
331            // This file was generated by the `theme_importer`.
332            // Be careful when modifying it by hand.
333
334            use gpui::rgba;
335
336            #[allow(unused)]
337            use crate::{{
338                Appearance, PlayerColor, PlayerColors, StatusColorsRefinement, ThemeColorsRefinement,
339                UserHighlightStyle, UserSyntaxTheme, UserTheme, UserThemeFamily, UserThemeStylesRefinement,
340                UserFontWeight, UserFontStyle
341            }};
342
343            pub fn {theme_family_slug}() -> UserThemeFamily {{
344                {theme_family_definition}
345            }}
346            "#,
347            theme_family_definition = format!("{:#?}", UserThemeFamilyPrinter::new(theme_family))
348        );
349
350        output_file.write_all(theme_module.as_bytes())?;
351
352        theme_modules.push(theme_family_slug);
353    }
354
355    theme_modules.sort();
356
357    let themes_vector_contents = format!(
358        r#"
359        use crate::UserThemeFamily;
360
361        pub(crate) fn all_user_themes() -> Vec<UserThemeFamily> {{
362            vec![{all_themes}]
363        }}
364        "#,
365        all_themes = theme_modules
366            .iter()
367            .map(|module| format!("{}()", module))
368            .collect::<Vec<_>>()
369            .join(", ")
370    );
371
372    let mod_rs_contents = format!(
373        r#"
374        // This file was generated by the `theme_importer`.
375        // Be careful when modifying it by hand.
376
377        {mod_statements}
378
379        {use_statements}
380
381        {themes_vector_contents}
382        "#,
383        mod_statements = theme_modules
384            .iter()
385            .map(|module| format!("mod {module};"))
386            .collect::<Vec<_>>()
387            .join("\n"),
388        use_statements = theme_modules
389            .iter()
390            .map(|module| format!("pub use {module}::*;"))
391            .collect::<Vec<_>>()
392            .join("\n"),
393        themes_vector_contents = themes_vector_contents
394    );
395
396    mod_rs_file.write_all(mod_rs_contents.as_bytes())?;
397
398    log::info!("Writing LICENSES file...");
399
400    let mut licenses_file = File::create(themes_output_path.join(format!("LICENSES")))?;
401
402    licenses_file.write_all(licenses.join("\n").as_bytes())?;
403
404    log::info!("Formatting themes...");
405
406    let format_result = format_themes_crate()
407        // We need to format a second time to catch all of the formatting issues.
408        .and_then(|_| format_themes_crate());
409
410    if let Err(err) = format_result {
411        log::error!("Failed to format themes: {}", err);
412    }
413
414    log::info!("Done!");
415
416    Ok(())
417}
418
419fn format_themes_crate() -> std::io::Result<std::process::Output> {
420    Command::new("cargo")
421        .args(["fmt", "--package", "theme"])
422        .output()
423}