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}