1use std::sync::LazyLock;
2
3use gpui::{Hsla, Rgba};
4use lsp::{CompletionItem, Documentation};
5use regex::{Regex, RegexBuilder};
6
7const HEX: &'static str = r#"(#(?:[\da-fA-F]{3}){1,2})"#;
8const RGB_OR_HSL: &'static 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 return 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 static COLOR_TABLE: LazyLock<Vec<(&'static str, Option<u32>)>> = LazyLock::new(|| {
145 vec![
146 // -- Invalid --
147 // Invalid hex
148 ("f0f", None),
149 ("#fof", None),
150 // Extra field
151 ("rgb(255, 0, 0, 0.0)", None),
152 ("hsl(120, 0, 0, 0.0)", None),
153 // Missing field
154 ("rgba(255, 0, 0)", None),
155 ("hsla(120, 0, 0)", None),
156 // No decimal after zero
157 ("rgba(255, 0, 0, 0)", None),
158 ("hsla(120, 0, 0, 0)", None),
159 // Decimal after one
160 ("rgba(255, 0, 0, 1.0)", None),
161 ("hsla(120, 0, 0, 1.0)", None),
162 // HEX (sRGB)
163 ("#f0f", Some(0xFF00FFFF)),
164 ("#ff0000", Some(0xFF0000FF)),
165 // RGB / RGBA (sRGB)
166 ("rgb(255, 0, 0)", Some(0xFF0000FF)),
167 ("rgba(255, 0, 0, 0.4)", Some(0xFF000066)),
168 ("rgba(255, 0, 0, 1)", Some(0xFF0000FF)),
169 ("rgb(20%, 0%, 0%)", Some(0x330000FF)),
170 ("rgba(20%, 0%, 0%, 1)", Some(0x330000FF)),
171 ("rgb(0%, 20%, 0%)", Some(0x003300FF)),
172 ("rgba(0%, 20%, 0%, 1)", Some(0x003300FF)),
173 ("rgb(0%, 0%, 20%)", Some(0x000033FF)),
174 ("rgba(0%, 0%, 20%, 1)", Some(0x000033FF)),
175 // HSL / HSLA (sRGB)
176 ("hsl(0, 100%, 50%)", Some(0xFF0000FF)),
177 ("hsl(120, 100%, 50%)", Some(0x00FF00FF)),
178 ("hsla(0, 100%, 50%, 0.0)", Some(0xFF000000)),
179 ("hsla(0, 100%, 50%, 0.4)", Some(0xFF000066)),
180 ("hsla(0, 100%, 50%, 1)", Some(0xFF0000FF)),
181 ("hsla(120, 100%, 50%, 0.0)", Some(0x00FF0000)),
182 ("hsla(120, 100%, 50%, 0.4)", Some(0x00FF0066)),
183 ("hsla(120, 100%, 50%, 1)", Some(0x00FF00FF)),
184 ]
185 });
186
187 #[test]
188 fn can_extract_from_label() {
189 for (color_str, color_val) in COLOR_TABLE.iter() {
190 let color = extract_color(&CompletionItem {
191 kind: Some(CompletionItemKind::COLOR),
192 label: color_str.to_string(),
193 detail: None,
194 documentation: None,
195 ..Default::default()
196 });
197
198 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
199 }
200 }
201
202 #[test]
203 fn only_whole_label_matches_are_allowed() {
204 for (color_str, _) in COLOR_TABLE.iter() {
205 let color = extract_color(&CompletionItem {
206 kind: Some(CompletionItemKind::COLOR),
207 label: format!("{} foo", color_str).to_string(),
208 detail: None,
209 documentation: None,
210 ..Default::default()
211 });
212
213 assert_eq!(color, None);
214 }
215 }
216
217 #[test]
218 fn can_extract_from_detail() {
219 for (color_str, color_val) in COLOR_TABLE.iter() {
220 let color = extract_color(&CompletionItem {
221 kind: Some(CompletionItemKind::COLOR),
222 label: "".to_string(),
223 detail: Some(color_str.to_string()),
224 documentation: None,
225 ..Default::default()
226 });
227
228 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
229 }
230 }
231
232 #[test]
233 fn only_whole_detail_matches_are_allowed() {
234 for (color_str, _) in COLOR_TABLE.iter() {
235 let color = extract_color(&CompletionItem {
236 kind: Some(CompletionItemKind::COLOR),
237 label: "".to_string(),
238 detail: Some(format!("{} foo", color_str).to_string()),
239 documentation: None,
240 ..Default::default()
241 });
242
243 assert_eq!(color, None);
244 }
245 }
246
247 #[test]
248 fn can_extract_from_documentation_start() {
249 for (color_str, color_val) in COLOR_TABLE.iter() {
250 let color = extract_color(&CompletionItem {
251 kind: Some(CompletionItemKind::COLOR),
252 label: "".to_string(),
253 detail: None,
254 documentation: Some(Documentation::String(
255 format!("{} foo", color_str).to_string(),
256 )),
257 ..Default::default()
258 });
259
260 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
261 }
262 }
263
264 #[test]
265 fn can_extract_from_documentation_end() {
266 for (color_str, color_val) in COLOR_TABLE.iter() {
267 let color = extract_color(&CompletionItem {
268 kind: Some(CompletionItemKind::COLOR),
269 label: "".to_string(),
270 detail: None,
271 documentation: Some(Documentation::String(
272 format!("foo {}", color_str).to_string(),
273 )),
274 ..Default::default()
275 });
276
277 assert_eq!(color, color_val.map(|v| Hsla::from(rgba(v))));
278 }
279 }
280
281 #[test]
282 fn cannot_extract_from_documentation_middle() {
283 for (color_str, _) in COLOR_TABLE.iter() {
284 let color = extract_color(&CompletionItem {
285 kind: Some(CompletionItemKind::COLOR),
286 label: "".to_string(),
287 detail: None,
288 documentation: Some(Documentation::String(
289 format!("foo {} foo", color_str).to_string(),
290 )),
291 ..Default::default()
292 });
293
294 assert_eq!(color, None);
295 }
296 }
297}