From 121e2bbe84b8b83d5f4352bad98832b5efdbcf44 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 31 Mar 2026 08:06:37 -0600 Subject: [PATCH] Extract `syntax_theme` crate (#52798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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(-) create mode 100644 crates/syntax_theme/Cargo.toml create mode 120000 crates/syntax_theme/LICENSE-APACHE create mode 120000 crates/syntax_theme/LICENSE-GPL create mode 100644 crates/syntax_theme/src/syntax_theme.rs diff --git a/Cargo.lock b/Cargo.lock index 66c7d8063072c8019bb8e5c09d884257b0545f44..81d82dcd46c85293f67a927405251bbc87df4967 100644 --- a/Cargo.lock +++ b/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", ] diff --git a/Cargo.toml b/Cargo.toml index 7c6fdb14defc7c060ee162a78f4319b2dff4deef..f3056b87fbdcc1ccc380b9fc0059df8a94b0c1f3 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/syntax_theme/Cargo.toml b/crates/syntax_theme/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..18f09c9e316a230abf01d043e3ea4aec11c854ef --- /dev/null +++ b/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"] } diff --git a/crates/syntax_theme/LICENSE-APACHE b/crates/syntax_theme/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..1cd601d0a3affae83854be02a0afdec3b7a9ec4d --- /dev/null +++ b/crates/syntax_theme/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/syntax_theme/LICENSE-GPL b/crates/syntax_theme/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/syntax_theme/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/syntax_theme/src/syntax_theme.rs b/crates/syntax_theme/src/syntax_theme.rs new file mode 100644 index 0000000000000000000000000000000000000000..edd02130b6e2feab6c3291817bde21b64aaddf50 --- /dev/null +++ b/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, + capture_name_map: BTreeMap, +} + +impl SyntaxTheme { + pub fn new(highlights: impl IntoIterator) -> 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) -> BTreeMap { + highlights + .into_iter() + .enumerate() + .map(|(i, key)| (key, i)) + .collect() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_test(colors: impl IntoIterator) -> 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, + ) -> Self { + Self::new( + colors + .into_iter() + .map(|(key, style)| (key.to_owned(), style)), + ) + } + + pub fn get(&self, highlight_index: impl Into) -> Option<&HighlightStyle> { + self.highlights.get(highlight_index.into()) + } + + pub fn style_for_name(&self, name: &str) -> Option { + self.capture_name_map + .get(name) + .map(|highlight_idx| self.highlights[*highlight_idx]) + } + + pub fn get_capture_name(&self, idx: impl Into) -> 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 { + self.capture_name_map + .range::(( + 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`] with the given syntax styles merged in. + pub fn merge(base: Arc, user_syntax_styles: Vec<(String, HighlightStyle)>) -> Arc { + 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, + } + + #[derive(Deserialize)] + struct ThemeEntry { + name: String, + style: ThemeStyle, + } + + #[derive(Deserialize)] + struct ThemeStyle { + syntax: BTreeMap, + } + + #[derive(Deserialize)] + struct SyntaxStyleEntry { + color: Option, + font_weight: Option, + font_style: Option, + } + + 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 { + 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 { + 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() + } + ) + ])) + ); + } +} diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index dcfa711554ec7457c63d5ce9c9488e337de78836..5bb624dd0c101aa978e296a7ff33c02b2faa99c1 100644 --- a/crates/theme/Cargo.toml +++ b/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 diff --git a/crates/theme/src/styles/syntax.rs b/crates/theme/src/styles/syntax.rs index faf21d54f1f581efa8e44e3e9b478ed32ef93ea9..b5c0a3016439e500c76e9b24775a137ddea7bbaa 100644 --- a/crates/theme/src/styles/syntax.rs +++ b/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, - pub(self) capture_name_map: BTreeMap, -} - -impl SyntaxTheme { - pub fn new(highlights: impl IntoIterator) -> 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) -> BTreeMap { - highlights - .into_iter() - .enumerate() - .map(|(i, key)| (key, i)) - .collect() - } - - #[cfg(any(test, feature = "test-support"))] - pub fn new_test(colors: impl IntoIterator) -> 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, - ) -> Self { - Self::new( - colors - .into_iter() - .map(|(key, style)| (key.to_owned(), style)), - ) - } - - pub fn get(&self, highlight_index: impl Into) -> Option<&HighlightStyle> { - self.highlights.get(highlight_index.into()) - } - - pub fn style_for_name(&self, name: &str) -> Option { - self.capture_name_map - .get(name) - .map(|highlight_idx| self.highlights[*highlight_idx]) - } - - pub fn get_capture_name(&self, idx: impl Into) -> 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 { - self.capture_name_map - .range::(( - 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`] with the given syntax styles merged in. - pub fn merge(base: Arc, user_syntax_styles: Vec<(String, HighlightStyle)>) -> Arc { - 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;