Extract `syntax_theme` crate (#52798)

Nathan Sobo created

Extract `SyntaxTheme` into its own lightweight crate so that downstream
consumers can use syntax highlighting colors without pulling in the full
`theme` crate and its transitive dependencies.

## Changes

**Commit 1 — Extract SyntaxTheme into its own crate**

Move `SyntaxTheme`, `SyntaxThemeSettings`, `HighlightStyle`, and
supporting types from `theme/src/styles/syntax.rs` into a new
`syntax_theme` crate that depends only on `gpui`. The `theme` crate
re-exports everything for backward compatibility — no call-site changes
needed.

**Commit 2 — Add `bundled-themes` feature with One Dark**

Add an optional `bundled-themes` feature that bundles `one_dark()`, a
`SyntaxTheme` loaded from the existing One Dark JSON theme file. This
lets consumers get a usable syntax theme without depending on the full
theme machinery.

Release Notes:

- N/A

Change summary

Cargo.lock                              |  10 
Cargo.toml                              |   2 
crates/syntax_theme/Cargo.toml          |  26 ++
crates/syntax_theme/LICENSE-APACHE      |   1 
crates/syntax_theme/LICENSE-GPL         |   1 
crates/syntax_theme/src/syntax_theme.rs | 336 +++++++++++++++++++++++++++
crates/theme/Cargo.toml                 |   3 
crates/theme/src/styles/syntax.rs       | 242 -------------------
8 files changed, 379 insertions(+), 242 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -17110,6 +17110,15 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "syntax_theme"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "sys-locale"
 version = "0.3.2"
@@ -17569,6 +17578,7 @@ dependencies = [
  "serde_json",
  "serde_json_lenient",
  "strum 0.27.2",
+ "syntax_theme",
  "thiserror 2.0.17",
  "uuid",
 ]

Cargo.toml 🔗

@@ -187,6 +187,7 @@ members = [
     "crates/sum_tree",
     "crates/svg_preview",
     "crates/system_specs",
+    "crates/syntax_theme",
     "crates/tab_switcher",
     "crates/task",
     "crates/tasks_ui",
@@ -435,6 +436,7 @@ streaming_diff = { path = "crates/streaming_diff" }
 sum_tree = { path = "crates/sum_tree" }
 codestral = { path = "crates/codestral" }
 system_specs = { path = "crates/system_specs" }
+syntax_theme = { path = "crates/syntax_theme" }
 tab_switcher = { path = "crates/tab_switcher" }
 task = { path = "crates/task" }
 tasks_ui = { path = "crates/tasks_ui" }

crates/syntax_theme/Cargo.toml 🔗

@@ -0,0 +1,26 @@
+[package]
+name = "syntax_theme"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[features]
+default = []
+test-support = ["gpui/test-support"]
+bundled-themes = ["dep:serde", "dep:serde_json"]
+
+[lib]
+path = "src/syntax_theme.rs"
+doctest = false
+
+[dependencies]
+gpui.workspace = true
+serde = { workspace = true, optional = true }
+serde_json = { workspace = true, optional = true }
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }

crates/syntax_theme/src/syntax_theme.rs 🔗

@@ -0,0 +1,336 @@
+#![allow(missing_docs)]
+
+use std::{
+    collections::{BTreeMap, btree_map::Entry},
+    sync::Arc,
+};
+
+use gpui::HighlightStyle;
+#[cfg(any(test, feature = "test-support"))]
+use gpui::Hsla;
+
+#[derive(Debug, PartialEq, Eq, Clone, Default)]
+pub struct SyntaxTheme {
+    highlights: Vec<HighlightStyle>,
+    capture_name_map: BTreeMap<String, usize>,
+}
+
+impl SyntaxTheme {
+    pub fn new(highlights: impl IntoIterator<Item = (String, HighlightStyle)>) -> Self {
+        let (capture_names, highlights) = highlights.into_iter().unzip();
+
+        Self {
+            capture_name_map: Self::create_capture_name_map(capture_names),
+            highlights,
+        }
+    }
+
+    fn create_capture_name_map(highlights: Vec<String>) -> BTreeMap<String, usize> {
+        highlights
+            .into_iter()
+            .enumerate()
+            .map(|(i, key)| (key, i))
+            .collect()
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new_test(colors: impl IntoIterator<Item = (&'static str, Hsla)>) -> Self {
+        Self::new_test_styles(colors.into_iter().map(|(key, color)| {
+            (
+                key,
+                HighlightStyle {
+                    color: Some(color),
+                    ..Default::default()
+                },
+            )
+        }))
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new_test_styles(
+        colors: impl IntoIterator<Item = (&'static str, HighlightStyle)>,
+    ) -> Self {
+        Self::new(
+            colors
+                .into_iter()
+                .map(|(key, style)| (key.to_owned(), style)),
+        )
+    }
+
+    pub fn get(&self, highlight_index: impl Into<usize>) -> Option<&HighlightStyle> {
+        self.highlights.get(highlight_index.into())
+    }
+
+    pub fn style_for_name(&self, name: &str) -> Option<HighlightStyle> {
+        self.capture_name_map
+            .get(name)
+            .map(|highlight_idx| self.highlights[*highlight_idx])
+    }
+
+    pub fn get_capture_name(&self, idx: impl Into<usize>) -> Option<&str> {
+        let idx = idx.into();
+        self.capture_name_map
+            .iter()
+            .find(|(_, value)| **value == idx)
+            .map(|(key, _)| key.as_ref())
+    }
+
+    pub fn highlight_id(&self, capture_name: &str) -> Option<u32> {
+        self.capture_name_map
+            .range::<str, _>((
+                capture_name.split(".").next().map_or(
+                    std::ops::Bound::Included(capture_name),
+                    std::ops::Bound::Included,
+                ),
+                std::ops::Bound::Included(capture_name),
+            ))
+            .rfind(|(prefix, _)| {
+                capture_name
+                    .strip_prefix(*prefix)
+                    .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.'))
+            })
+            .map(|(_, index)| *index as u32)
+    }
+
+    /// Returns a new [`Arc<SyntaxTheme>`] with the given syntax styles merged in.
+    pub fn merge(base: Arc<Self>, user_syntax_styles: Vec<(String, HighlightStyle)>) -> Arc<Self> {
+        if user_syntax_styles.is_empty() {
+            return base;
+        }
+
+        let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone());
+
+        for (name, highlight) in user_syntax_styles {
+            match base.capture_name_map.entry(name) {
+                Entry::Occupied(entry) => {
+                    if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) {
+                        existing_highlight.color = highlight.color.or(existing_highlight.color);
+                        existing_highlight.font_weight =
+                            highlight.font_weight.or(existing_highlight.font_weight);
+                        existing_highlight.font_style =
+                            highlight.font_style.or(existing_highlight.font_style);
+                        existing_highlight.background_color = highlight
+                            .background_color
+                            .or(existing_highlight.background_color);
+                        existing_highlight.underline =
+                            highlight.underline.or(existing_highlight.underline);
+                        existing_highlight.strikethrough =
+                            highlight.strikethrough.or(existing_highlight.strikethrough);
+                        existing_highlight.fade_out =
+                            highlight.fade_out.or(existing_highlight.fade_out);
+                    }
+                }
+                Entry::Vacant(vacant) => {
+                    vacant.insert(base.highlights.len());
+                    base.highlights.push(highlight);
+                }
+            }
+        }
+
+        Arc::new(base)
+    }
+}
+
+#[cfg(feature = "bundled-themes")]
+mod bundled_themes {
+    use std::collections::BTreeMap;
+    use std::sync::Arc;
+
+    use gpui::{FontStyle, FontWeight, HighlightStyle, Hsla, Rgba, rgb};
+    use serde::Deserialize;
+
+    use super::SyntaxTheme;
+
+    #[derive(Deserialize)]
+    struct ThemeFile {
+        themes: Vec<ThemeEntry>,
+    }
+
+    #[derive(Deserialize)]
+    struct ThemeEntry {
+        name: String,
+        style: ThemeStyle,
+    }
+
+    #[derive(Deserialize)]
+    struct ThemeStyle {
+        syntax: BTreeMap<String, SyntaxStyleEntry>,
+    }
+
+    #[derive(Deserialize)]
+    struct SyntaxStyleEntry {
+        color: Option<String>,
+        font_weight: Option<f32>,
+        font_style: Option<String>,
+    }
+
+    impl SyntaxStyleEntry {
+        fn to_highlight_style(&self) -> HighlightStyle {
+            HighlightStyle {
+                color: self.color.as_deref().map(hex_to_hsla),
+                font_weight: self.font_weight.map(FontWeight),
+                font_style: self.font_style.as_deref().and_then(|s| match s {
+                    "italic" => Some(FontStyle::Italic),
+                    "normal" => Some(FontStyle::Normal),
+                    "oblique" => Some(FontStyle::Oblique),
+                    _ => None,
+                }),
+                ..Default::default()
+            }
+        }
+    }
+
+    fn hex_to_hsla(hex: &str) -> Hsla {
+        let hex = hex.trim_start_matches('#');
+        let rgba: Rgba = match hex.len() {
+            6 => rgb(u32::from_str_radix(hex, 16).unwrap_or(0)),
+            8 => {
+                let value = u32::from_str_radix(hex, 16).unwrap_or(0);
+                Rgba {
+                    r: ((value >> 24) & 0xff) as f32 / 255.0,
+                    g: ((value >> 16) & 0xff) as f32 / 255.0,
+                    b: ((value >> 8) & 0xff) as f32 / 255.0,
+                    a: (value & 0xff) as f32 / 255.0,
+                }
+            }
+            _ => rgb(0),
+        };
+        rgba.into()
+    }
+
+    fn load_theme(json: &str, theme_name: &str) -> Arc<SyntaxTheme> {
+        let theme_file: ThemeFile = serde_json::from_str(json).expect("failed to parse theme JSON");
+        let theme_entry = theme_file
+            .themes
+            .iter()
+            .find(|entry| entry.name == theme_name)
+            .unwrap_or_else(|| panic!("theme {theme_name:?} not found in theme JSON"));
+
+        let highlights = theme_entry
+            .style
+            .syntax
+            .iter()
+            .map(|(name, entry)| (name.clone(), entry.to_highlight_style()));
+
+        Arc::new(SyntaxTheme::new(highlights))
+    }
+
+    impl SyntaxTheme {
+        /// Load the "One Dark" syntax theme from the bundled theme JSON.
+        pub fn one_dark() -> Arc<Self> {
+            load_theme(
+                include_str!("../../../assets/themes/one/one.json"),
+                "One Dark",
+            )
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use gpui::FontStyle;
+
+    use super::*;
+
+    #[test]
+    fn test_syntax_theme_merge() {
+        // Merging into an empty `SyntaxTheme` keeps all the user-defined styles.
+        let syntax_theme = SyntaxTheme::merge(
+            Arc::new(SyntaxTheme::new_test([])),
+            vec![
+                (
+                    "foo".to_string(),
+                    HighlightStyle {
+                        color: Some(gpui::red()),
+                        ..Default::default()
+                    },
+                ),
+                (
+                    "foo.bar".to_string(),
+                    HighlightStyle {
+                        color: Some(gpui::green()),
+                        ..Default::default()
+                    },
+                ),
+            ],
+        );
+        assert_eq!(
+            syntax_theme,
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::red()),
+                ("foo.bar", gpui::green())
+            ]))
+        );
+
+        // Merging empty user-defined styles keeps all the base styles.
+        let syntax_theme = SyntaxTheme::merge(
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::blue()),
+                ("foo.bar", gpui::red()),
+            ])),
+            Vec::new(),
+        );
+        assert_eq!(
+            syntax_theme,
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::blue()),
+                ("foo.bar", gpui::red())
+            ]))
+        );
+
+        let syntax_theme = SyntaxTheme::merge(
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::red()),
+                ("foo.bar", gpui::green()),
+            ])),
+            vec![(
+                "foo.bar".to_string(),
+                HighlightStyle {
+                    color: Some(gpui::yellow()),
+                    ..Default::default()
+                },
+            )],
+        );
+        assert_eq!(
+            syntax_theme,
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::red()),
+                ("foo.bar", gpui::yellow())
+            ]))
+        );
+
+        let syntax_theme = SyntaxTheme::merge(
+            Arc::new(SyntaxTheme::new_test([
+                ("foo", gpui::red()),
+                ("foo.bar", gpui::green()),
+            ])),
+            vec![(
+                "foo.bar".to_string(),
+                HighlightStyle {
+                    font_style: Some(FontStyle::Italic),
+                    ..Default::default()
+                },
+            )],
+        );
+        assert_eq!(
+            syntax_theme,
+            Arc::new(SyntaxTheme::new_test_styles([
+                (
+                    "foo",
+                    HighlightStyle {
+                        color: Some(gpui::red()),
+                        ..Default::default()
+                    }
+                ),
+                (
+                    "foo.bar",
+                    HighlightStyle {
+                        color: Some(gpui::green()),
+                        font_style: Some(FontStyle::Italic),
+                        ..Default::default()
+                    }
+                )
+            ]))
+        );
+    }
+}

crates/theme/Cargo.toml 🔗

@@ -10,7 +10,7 @@ workspace = true
 
 [features]
 default = []
-test-support = ["gpui/test-support"]
+test-support = ["gpui/test-support", "syntax_theme/test-support"]
 
 [lib]
 path = "src/theme.rs"
@@ -21,6 +21,7 @@ anyhow.workspace = true
 collections.workspace = true
 derive_more.workspace = true
 gpui.workspace = true
+syntax_theme.workspace = true
 palette = { workspace = true, default-features = false, features = ["std"] }
 parking_lot.workspace = true
 refineable.workspace = true

crates/theme/src/styles/syntax.rs 🔗

@@ -1,241 +1 @@
-#![allow(missing_docs)]
-
-use std::{
-    collections::{BTreeMap, btree_map::Entry},
-    sync::Arc,
-};
-
-use gpui::HighlightStyle;
-#[cfg(any(test, feature = "test-support"))]
-use gpui::Hsla;
-
-#[derive(Debug, PartialEq, Eq, Clone, Default)]
-pub struct SyntaxTheme {
-    pub(self) highlights: Vec<HighlightStyle>,
-    pub(self) capture_name_map: BTreeMap<String, usize>,
-}
-
-impl SyntaxTheme {
-    pub fn new(highlights: impl IntoIterator<Item = (String, HighlightStyle)>) -> Self {
-        let (capture_names, highlights) = highlights.into_iter().unzip();
-
-        Self {
-            capture_name_map: Self::create_capture_name_map(capture_names),
-            highlights,
-        }
-    }
-
-    fn create_capture_name_map(highlights: Vec<String>) -> BTreeMap<String, usize> {
-        highlights
-            .into_iter()
-            .enumerate()
-            .map(|(i, key)| (key, i))
-            .collect()
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn new_test(colors: impl IntoIterator<Item = (&'static str, Hsla)>) -> Self {
-        Self::new_test_styles(colors.into_iter().map(|(key, color)| {
-            (
-                key,
-                HighlightStyle {
-                    color: Some(color),
-                    ..Default::default()
-                },
-            )
-        }))
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn new_test_styles(
-        colors: impl IntoIterator<Item = (&'static str, HighlightStyle)>,
-    ) -> Self {
-        Self::new(
-            colors
-                .into_iter()
-                .map(|(key, style)| (key.to_owned(), style)),
-        )
-    }
-
-    pub fn get(&self, highlight_index: impl Into<usize>) -> Option<&HighlightStyle> {
-        self.highlights.get(highlight_index.into())
-    }
-
-    pub fn style_for_name(&self, name: &str) -> Option<HighlightStyle> {
-        self.capture_name_map
-            .get(name)
-            .map(|highlight_idx| self.highlights[*highlight_idx])
-    }
-
-    pub fn get_capture_name(&self, idx: impl Into<usize>) -> Option<&str> {
-        let idx = idx.into();
-        self.capture_name_map
-            .iter()
-            .find(|(_, value)| **value == idx)
-            .map(|(key, _)| key.as_ref())
-    }
-
-    pub fn highlight_id(&self, capture_name: &str) -> Option<u32> {
-        self.capture_name_map
-            .range::<str, _>((
-                capture_name.split(".").next().map_or(
-                    std::ops::Bound::Included(capture_name),
-                    std::ops::Bound::Included,
-                ),
-                std::ops::Bound::Included(capture_name),
-            ))
-            .rfind(|(prefix, _)| {
-                capture_name
-                    .strip_prefix(*prefix)
-                    .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.'))
-            })
-            .map(|(_, index)| *index as u32)
-    }
-
-    /// Returns a new [`Arc<SyntaxTheme>`] with the given syntax styles merged in.
-    pub fn merge(base: Arc<Self>, user_syntax_styles: Vec<(String, HighlightStyle)>) -> Arc<Self> {
-        if user_syntax_styles.is_empty() {
-            return base;
-        }
-
-        let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone());
-
-        for (name, highlight) in user_syntax_styles {
-            match base.capture_name_map.entry(name) {
-                Entry::Occupied(entry) => {
-                    if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) {
-                        existing_highlight.color = highlight.color.or(existing_highlight.color);
-                        existing_highlight.font_weight =
-                            highlight.font_weight.or(existing_highlight.font_weight);
-                        existing_highlight.font_style =
-                            highlight.font_style.or(existing_highlight.font_style);
-                        existing_highlight.background_color = highlight
-                            .background_color
-                            .or(existing_highlight.background_color);
-                        existing_highlight.underline =
-                            highlight.underline.or(existing_highlight.underline);
-                        existing_highlight.strikethrough =
-                            highlight.strikethrough.or(existing_highlight.strikethrough);
-                        existing_highlight.fade_out =
-                            highlight.fade_out.or(existing_highlight.fade_out);
-                    }
-                }
-                Entry::Vacant(vacant) => {
-                    vacant.insert(base.highlights.len());
-                    base.highlights.push(highlight);
-                }
-            }
-        }
-
-        Arc::new(base)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use gpui::FontStyle;
-
-    use super::*;
-
-    #[test]
-    fn test_syntax_theme_merge() {
-        // Merging into an empty `SyntaxTheme` keeps all the user-defined styles.
-        let syntax_theme = SyntaxTheme::merge(
-            Arc::new(SyntaxTheme::new_test([])),
-            vec![
-                (
-                    "foo".to_string(),
-                    HighlightStyle {
-                        color: Some(gpui::red()),
-                        ..Default::default()
-                    },
-                ),
-                (
-                    "foo.bar".to_string(),
-                    HighlightStyle {
-                        color: Some(gpui::green()),
-                        ..Default::default()
-                    },
-                ),
-            ],
-        );
-        assert_eq!(
-            syntax_theme,
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::red()),
-                ("foo.bar", gpui::green())
-            ]))
-        );
-
-        // Merging empty user-defined styles keeps all the base styles.
-        let syntax_theme = SyntaxTheme::merge(
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::blue()),
-                ("foo.bar", gpui::red()),
-            ])),
-            Vec::new(),
-        );
-        assert_eq!(
-            syntax_theme,
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::blue()),
-                ("foo.bar", gpui::red())
-            ]))
-        );
-
-        let syntax_theme = SyntaxTheme::merge(
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::red()),
-                ("foo.bar", gpui::green()),
-            ])),
-            vec![(
-                "foo.bar".to_string(),
-                HighlightStyle {
-                    color: Some(gpui::yellow()),
-                    ..Default::default()
-                },
-            )],
-        );
-        assert_eq!(
-            syntax_theme,
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::red()),
-                ("foo.bar", gpui::yellow())
-            ]))
-        );
-
-        let syntax_theme = SyntaxTheme::merge(
-            Arc::new(SyntaxTheme::new_test([
-                ("foo", gpui::red()),
-                ("foo.bar", gpui::green()),
-            ])),
-            vec![(
-                "foo.bar".to_string(),
-                HighlightStyle {
-                    font_style: Some(FontStyle::Italic),
-                    ..Default::default()
-                },
-            )],
-        );
-        assert_eq!(
-            syntax_theme,
-            Arc::new(SyntaxTheme::new_test_styles([
-                (
-                    "foo",
-                    HighlightStyle {
-                        color: Some(gpui::red()),
-                        ..Default::default()
-                    }
-                ),
-                (
-                    "foo.bar",
-                    HighlightStyle {
-                        color: Some(gpui::green()),
-                        font_style: Some(FontStyle::Italic),
-                        ..Default::default()
-                    }
-                )
-            ]))
-        );
-    }
-}
+pub use syntax_theme::SyntaxTheme;