color_extractor.rs

  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}