1use std::sync::LazyLock;
2
3use gpui::{Hsla, Rgba};
4use lsp::{CompletionItem, Documentation};
5use regex::{Regex, RegexBuilder};
6
7const HEX: &str = r#"(#(?:[\da-fA-F]{3}){1,2})"#;
8const RGB_OR_HSL: &str = r#"(rgba?|hsla?)\(\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*(?:,\s*(1|0?\.\d+))?\s*\)"#;
9
10static RELAXED_HEX_REGEX: LazyLock<Regex> = LazyLock::new(|| {
11 RegexBuilder::new(HEX)
12 .case_insensitive(false)
13 .build()
14 .expect("Failed to create RELAXED_HEX_REGEX")
15});
16
17static STRICT_HEX_REGEX: LazyLock<Regex> = LazyLock::new(|| {
18 RegexBuilder::new(&format!("^{HEX}$"))
19 .case_insensitive(true)
20 .build()
21 .expect("Failed to create STRICT_HEX_REGEX")
22});
23
24static RELAXED_RGB_OR_HSL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
25 RegexBuilder::new(RGB_OR_HSL)
26 .case_insensitive(false)
27 .build()
28 .expect("Failed to create RELAXED_RGB_OR_HSL_REGEX")
29});
30
31static STRICT_RGB_OR_HSL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
32 RegexBuilder::new(&format!("^{RGB_OR_HSL}$"))
33 .case_insensitive(true)
34 .build()
35 .expect("Failed to create STRICT_RGB_OR_HSL_REGEX")
36});
37
38/// Extracts a color from an LSP [`CompletionItem`].
39///
40/// Adapted from https://github.com/microsoft/vscode/blob/a6870fcb6d79093738c17e8319b760cf1c41764a/src/vs/editor/contrib/suggest/browser/suggestWidgetRenderer.ts#L34-L61
41pub fn extract_color(item: &CompletionItem) -> Option<Hsla> {
42 // Try to extract from entire `label` field.
43 parse(&item.label, ParseMode::Strict)
44 // Try to extract from entire `detail` field.
45 .or_else(|| {
46 item.detail
47 .as_ref()
48 .and_then(|detail| parse(detail, ParseMode::Strict))
49 })
50 // Try to extract from beginning or end of `documentation` field.
51 .or_else(|| match item.documentation {
52 Some(Documentation::String(ref str)) => parse(str, ParseMode::Relaxed),
53 Some(Documentation::MarkupContent(ref markup)) => {
54 parse(&markup.value, ParseMode::Relaxed)
55 }
56 None => None,
57 })
58}
59
60enum ParseMode {
61 Strict,
62 Relaxed,
63}
64
65fn parse(str: &str, mode: ParseMode) -> Option<Hsla> {
66 let (hex, rgb) = match mode {
67 ParseMode::Strict => (&STRICT_HEX_REGEX, &STRICT_RGB_OR_HSL_REGEX),
68 ParseMode::Relaxed => (&RELAXED_HEX_REGEX, &RELAXED_RGB_OR_HSL_REGEX),
69 };
70
71 if let Some(captures) = hex.captures(str) {
72 let rmatch = captures.get(0)?;
73
74 // Color must be anchored to start or end of string.
75 if rmatch.start() > 0 && rmatch.end() != str.len() {
76 return None;
77 }
78
79 let hex = captures.get(1)?.as_str();
80
81 return from_hex(hex);
82 }
83
84 if let Some(captures) = rgb.captures(str) {
85 let rmatch = captures.get(0)?;
86
87 // Color must be anchored to start or end of string.
88 if rmatch.start() > 0 && rmatch.end() != str.len() {
89 return None;
90 }
91
92 let typ = captures.get(1)?.as_str();
93 let r_or_h = captures.get(2)?.as_str();
94 let g_or_s = captures.get(3)?.as_str();
95 let b_or_l = captures.get(4)?.as_str();
96 let a = captures.get(5).map(|a| a.as_str());
97
98 return match (typ, a) {
99 ("rgb", None) | ("rgba", Some(_)) => from_rgb(r_or_h, g_or_s, b_or_l, a),
100 ("hsl", None) | ("hsla", Some(_)) => from_hsl(r_or_h, g_or_s, b_or_l, a),
101 _ => None,
102 };
103 }
104
105 None
106}
107
108fn parse_component(value: &str, max: f32) -> Option<f32> {
109 if let Some(field) = value.strip_suffix("%") {
110 field.parse::<f32>().map(|value| value / 100.).ok()
111 } else {
112 value.parse::<f32>().map(|value| value / max).ok()
113 }
114}
115
116fn from_hex(hex: &str) -> Option<Hsla> {
117 Rgba::try_from(hex).map(Hsla::from).ok()
118}
119
120fn from_rgb(r: &str, g: &str, b: &str, a: Option<&str>) -> Option<Hsla> {
121 let r = parse_component(r, 255.)?;
122 let g = parse_component(g, 255.)?;
123 let b = parse_component(b, 255.)?;
124 let a = a.and_then(|a| parse_component(a, 1.0)).unwrap_or(1.0);
125
126 Some(Rgba { r, g, b, a }.into())
127}
128
129fn from_hsl(h: &str, s: &str, l: &str, a: Option<&str>) -> Option<Hsla> {
130 let h = parse_component(h, 360.)?;
131 let s = parse_component(s, 100.)?;
132 let l = parse_component(l, 100.)?;
133 let a = a.and_then(|a| parse_component(a, 1.0)).unwrap_or(1.0);
134
135 Some(Hsla { h, s, l, a })
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use gpui::rgba;
142 use lsp::{CompletionItem, CompletionItemKind};
143
144 pub const COLOR_TABLE: &[(&str, Option<u32>)] = &[
145 // -- Invalid --
146 // Invalid hex
147 ("f0f", None),
148 ("#fof", None),
149 // Extra field
150 ("rgb(255, 0, 0, 0.0)", None),
151 ("hsl(120, 0, 0, 0.0)", None),
152 // Missing field
153 ("rgba(255, 0, 0)", None),
154 ("hsla(120, 0, 0)", None),
155 // No decimal after zero
156 ("rgba(255, 0, 0, 0)", None),
157 ("hsla(120, 0, 0, 0)", None),
158 // Decimal after one
159 ("rgba(255, 0, 0, 1.0)", None),
160 ("hsla(120, 0, 0, 1.0)", None),
161 // HEX (sRGB)
162 ("#f0f", Some(0xFF00FFFF)),
163 ("#ff0000", Some(0xFF0000FF)),
164 // RGB / RGBA (sRGB)
165 ("rgb(255, 0, 0)", Some(0xFF0000FF)),
166 ("rgba(255, 0, 0, 0.4)", Some(0xFF000066)),
167 ("rgba(255, 0, 0, 1)", Some(0xFF0000FF)),
168 ("rgb(20%, 0%, 0%)", Some(0x330000FF)),
169 ("rgba(20%, 0%, 0%, 1)", Some(0x330000FF)),
170 ("rgb(0%, 20%, 0%)", Some(0x003300FF)),
171 ("rgba(0%, 20%, 0%, 1)", Some(0x003300FF)),
172 ("rgb(0%, 0%, 20%)", Some(0x000033FF)),
173 ("rgba(0%, 0%, 20%, 1)", Some(0x000033FF)),
174 // HSL / HSLA (sRGB)
175 ("hsl(0, 100%, 50%)", Some(0xFF0000FF)),
176 ("hsl(120, 100%, 50%)", Some(0x00FF00FF)),
177 ("hsla(0, 100%, 50%, 0.0)", Some(0xFF000000)),
178 ("hsla(0, 100%, 50%, 0.4)", Some(0xFF000066)),
179 ("hsla(0, 100%, 50%, 1)", Some(0xFF0000FF)),
180 ("hsla(120, 100%, 50%, 0.0)", Some(0x00FF0000)),
181 ("hsla(120, 100%, 50%, 0.4)", Some(0x00FF0066)),
182 ("hsla(120, 100%, 50%, 1)", Some(0x00FF00FF)),
183 ];
184
185 #[test]
186 fn can_extract_from_label() {
187 for (color_str, color_val) in COLOR_TABLE.iter() {
188 let color = extract_color(&CompletionItem {
189 kind: Some(CompletionItemKind::COLOR),
190 label: color_str.to_string(),
191 detail: None,
192 documentation: None,
193 ..Default::default()
194 });
195
196 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
197 }
198 }
199
200 #[test]
201 fn only_whole_label_matches_are_allowed() {
202 for (color_str, _) in COLOR_TABLE.iter() {
203 let color = extract_color(&CompletionItem {
204 kind: Some(CompletionItemKind::COLOR),
205 label: format!("{} foo", color_str).to_string(),
206 detail: None,
207 documentation: None,
208 ..Default::default()
209 });
210
211 assert_eq!(color, None);
212 }
213 }
214
215 #[test]
216 fn can_extract_from_detail() {
217 for (color_str, color_val) in COLOR_TABLE.iter() {
218 let color = extract_color(&CompletionItem {
219 kind: Some(CompletionItemKind::COLOR),
220 label: "".to_string(),
221 detail: Some(color_str.to_string()),
222 documentation: None,
223 ..Default::default()
224 });
225
226 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
227 }
228 }
229
230 #[test]
231 fn only_whole_detail_matches_are_allowed() {
232 for (color_str, _) in COLOR_TABLE.iter() {
233 let color = extract_color(&CompletionItem {
234 kind: Some(CompletionItemKind::COLOR),
235 label: "".to_string(),
236 detail: Some(format!("{} foo", color_str).to_string()),
237 documentation: None,
238 ..Default::default()
239 });
240
241 assert_eq!(color, None);
242 }
243 }
244
245 #[test]
246 fn can_extract_from_documentation_start() {
247 for (color_str, color_val) in COLOR_TABLE.iter() {
248 let color = extract_color(&CompletionItem {
249 kind: Some(CompletionItemKind::COLOR),
250 label: "".to_string(),
251 detail: None,
252 documentation: Some(Documentation::String(
253 format!("{} foo", color_str).to_string(),
254 )),
255 ..Default::default()
256 });
257
258 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
259 }
260 }
261
262 #[test]
263 fn can_extract_from_documentation_end() {
264 for (color_str, color_val) in COLOR_TABLE.iter() {
265 let color = extract_color(&CompletionItem {
266 kind: Some(CompletionItemKind::COLOR),
267 label: "".to_string(),
268 detail: None,
269 documentation: Some(Documentation::String(
270 format!("foo {}", color_str).to_string(),
271 )),
272 ..Default::default()
273 });
274
275 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
276 }
277 }
278
279 #[test]
280 fn cannot_extract_from_documentation_middle() {
281 for (color_str, _) in COLOR_TABLE.iter() {
282 let color = extract_color(&CompletionItem {
283 kind: Some(CompletionItemKind::COLOR),
284 label: "".to_string(),
285 detail: None,
286 documentation: Some(Documentation::String(
287 format!("foo {} foo", color_str).to_string(),
288 )),
289 ..Default::default()
290 });
291
292 assert_eq!(color, None);
293 }
294 }
295}