Skip to main content

css_variable_lsp/
color.rs

1use csscolorparser::Color as CssColor;
2use ls_types::{Color, ColorPresentation, Range, TextEdit};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
5pub struct NormalizedColorKey {
6    pub red: u8,
7    pub green: u8,
8    pub blue: u8,
9    pub alpha: u8,
10}
11
12/// Parse a CSS color value and return an LSP Color
13pub fn parse_color(value: &str) -> Option<Color> {
14    parse_csscolorparser(value.trim())
15}
16
17pub fn normalized_color_key(value: &str) -> Option<NormalizedColorKey> {
18    parse_color(value).map(normalize_color)
19}
20
21pub fn normalize_color(color: Color) -> NormalizedColorKey {
22    NormalizedColorKey {
23        red: color_channel_to_u8(color.red),
24        green: color_channel_to_u8(color.green),
25        blue: color_channel_to_u8(color.blue),
26        alpha: color_channel_to_u8(color.alpha),
27    }
28}
29
30pub fn color_from_key(key: NormalizedColorKey) -> Color {
31    Color {
32        red: key.red as f32 / 255.0,
33        green: key.green as f32 / 255.0,
34        blue: key.blue as f32 / 255.0,
35        alpha: key.alpha as f32 / 255.0,
36    }
37}
38
39fn strip_ascii_suffix_case_insensitive<'a>(value: &'a str, suffix: &str) -> Option<&'a str> {
40    let start = value.len().checked_sub(suffix.len())?;
41    value
42        .get(start..)
43        .filter(|tail| tail.eq_ignore_ascii_case(suffix))
44        .map(|_| &value[..start])
45}
46
47fn contains_nonfinite_numeric_token(value: &str) -> bool {
48    for raw_token in value.split(|character: char| {
49        character == '('
50            || character == ')'
51            || character == ','
52            || character == '/'
53            || character.is_ascii_whitespace()
54    }) {
55        let token = raw_token.trim();
56        if token.is_empty() {
57            continue;
58        }
59
60        // CSS color channels can carry a percentage or angle unit. Strip the
61        // unit before checking the number so values such as `infdeg` and
62        // `NaN%` cannot be accepted after the dependency clamps them.
63        let numeric = strip_ascii_suffix_case_insensitive(token, "%")
64            .or_else(|| strip_ascii_suffix_case_insensitive(token, "deg"))
65            .or_else(|| strip_ascii_suffix_case_insensitive(token, "grad"))
66            .or_else(|| strip_ascii_suffix_case_insensitive(token, "rad"))
67            .or_else(|| strip_ascii_suffix_case_insensitive(token, "turn"))
68            .unwrap_or(token);
69
70        let unsigned = numeric
71            .strip_prefix('+')
72            .or_else(|| numeric.strip_prefix('-'))
73            .unwrap_or(numeric);
74        if unsigned.eq_ignore_ascii_case("nan")
75            || unsigned.eq_ignore_ascii_case("inf")
76            || unsigned.eq_ignore_ascii_case("infinity")
77        {
78            return true;
79        }
80
81        if let Ok(number) = numeric.parse::<f64>() {
82            if !number.is_finite() {
83                return true;
84            }
85        }
86    }
87
88    false
89}
90
91fn parse_csscolorparser(value: &str) -> Option<Color> {
92    if contains_nonfinite_numeric_token(value) {
93        return None;
94    }
95
96    let parsed: CssColor = value.parse().ok()?;
97
98    // csscolorparser accepts any value that `f64::parse` accepts for numeric
99    // channels, including `NaN` and infinities. It also leaves HWB alpha
100    // outside the nominal range untouched. Never let either case cross the
101    // LSP boundary: JSON has no representation for non-finite numbers and
102    // LSP Color channels are defined in the inclusive [0, 1] range.
103    let channels = [parsed.r, parsed.g, parsed.b, parsed.a];
104    if channels.iter().any(|channel| !channel.is_finite()) {
105        return None;
106    }
107
108    Some(Color {
109        red: parsed.r.clamp(0.0, 1.0) as f32,
110        green: parsed.g.clamp(0.0, 1.0) as f32,
111        blue: parsed.b.clamp(0.0, 1.0) as f32,
112        alpha: parsed.a.clamp(0.0, 1.0) as f32,
113    })
114}
115
116/// Generate color presentations for color picker
117pub fn generate_color_presentations(color: Color, range: Range) -> Vec<ColorPresentation> {
118    let mut presentations = Vec::new();
119
120    let hex_str = format_color_as_hex(color);
121    presentations.push(ColorPresentation {
122        label: hex_str.clone(),
123        text_edit: Some(TextEdit {
124            range,
125            new_text: hex_str,
126        }),
127        additional_text_edits: None,
128    });
129
130    let rgb_str = format_color_as_rgb(color);
131    presentations.push(ColorPresentation {
132        label: rgb_str.clone(),
133        text_edit: Some(TextEdit {
134            range,
135            new_text: rgb_str,
136        }),
137        additional_text_edits: None,
138    });
139
140    let hsl_str = format_color_as_hsl(color);
141    presentations.push(ColorPresentation {
142        label: hsl_str.clone(),
143        text_edit: Some(TextEdit {
144            range,
145            new_text: hsl_str,
146        }),
147        additional_text_edits: None,
148    });
149
150    presentations
151}
152
153pub fn format_color_as_hex(color: Color) -> String {
154    let r = (color.red.clamp(0.0, 1.0) * 255.0).round() as u8;
155    let g = (color.green.clamp(0.0, 1.0) * 255.0).round() as u8;
156    let b = (color.blue.clamp(0.0, 1.0) * 255.0).round() as u8;
157    let a = (color.alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
158
159    if a == 255 {
160        format!("#{:02x}{:02x}{:02x}", r, g, b)
161    } else {
162        format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
163    }
164}
165
166pub fn format_color_as_rgb(color: Color) -> String {
167    let r = (color.red.clamp(0.0, 1.0) * 255.0).round() as u8;
168    let g = (color.green.clamp(0.0, 1.0) * 255.0).round() as u8;
169    let b = (color.blue.clamp(0.0, 1.0) * 255.0).round() as u8;
170    let a = color.alpha.clamp(0.0, 1.0);
171
172    if a >= 1.0 {
173        format!("rgb({}, {}, {})", r, g, b)
174    } else {
175        format!("rgba({}, {}, {}, {:.2})", r, g, b, a)
176    }
177}
178
179pub fn format_color_as_hsl(color: Color) -> String {
180    let (h, s, l) = rgb_to_hsl(color.red, color.green, color.blue);
181    let a = color.alpha.clamp(0.0, 1.0);
182
183    let h_deg = (h * 360.0).round();
184    let s_pct = (s * 100.0).round();
185    let l_pct = (l * 100.0).round();
186
187    if a >= 1.0 {
188        format!("hsl({}, {}%, {}%)", h_deg, s_pct, l_pct)
189    } else {
190        format!("hsla({}, {}%, {}%, {:.2})", h_deg, s_pct, l_pct, a)
191    }
192}
193
194fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
195    let max = r.max(g).max(b);
196    let min = r.min(g).min(b);
197    let l = (max + min) / 2.0;
198
199    if max == min {
200        return (0.0, 0.0, l);
201    }
202
203    let d = max - min;
204    let s = if l > 0.5 {
205        d / (2.0 - max - min)
206    } else {
207        d / (max + min)
208    };
209
210    let h = if max == r {
211        ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
212    } else if max == g {
213        ((b - r) / d + 2.0) / 6.0
214    } else {
215        ((r - g) / d + 4.0) / 6.0
216    };
217
218    (h, s, l)
219}
220
221fn color_channel_to_u8(channel: f32) -> u8 {
222    (channel.clamp(0.0, 1.0) * 255.0).round() as u8
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use ls_types::Position;
229
230    fn approx_eq(a: f32, b: f32) -> bool {
231        (a - b).abs() < 0.01
232    }
233
234    #[test]
235    fn parse_color_hex_and_named() {
236        let color = parse_color("#abc").expect("hex");
237        assert!(approx_eq(color.red, 0xAA as f32 / 255.0));
238        assert!(approx_eq(color.green, 0xBB as f32 / 255.0));
239        assert!(approx_eq(color.blue, 0xCC as f32 / 255.0));
240        assert!(approx_eq(color.alpha, 1.0));
241
242        let color = parse_color("#abcd").expect("hex with alpha");
243        assert!(approx_eq(color.red, 0xAA as f32 / 255.0));
244        assert!(approx_eq(color.alpha, 0xDD as f32 / 255.0));
245
246        let color = parse_color("blue").expect("named");
247        assert!(approx_eq(color.blue, 1.0));
248        assert!(approx_eq(color.red, 0.0));
249    }
250
251    #[test]
252    fn parse_color_rgb_variants() {
253        let color = parse_color("rgb(255, 0, 128)").expect("rgb");
254        assert!(approx_eq(color.red, 1.0));
255        assert!(approx_eq(color.green, 0.0));
256        assert!(approx_eq(color.blue, 128.0 / 255.0));
257
258        let color = parse_color("rgba(255, 0, 0, 0.5)").expect("rgba");
259        assert!(approx_eq(color.red, 1.0));
260        assert!(approx_eq(color.alpha, 0.5));
261
262        let color = parse_color("rgb(100%, 0%, 50%)").expect("rgb percent");
263        assert!(approx_eq(color.red, 1.0));
264        assert!(approx_eq(color.blue, 0.5));
265
266        let color = parse_color("rgba(255, 0, 0, 50%)").expect("rgba percent");
267        assert!(approx_eq(color.alpha, 0.5));
268    }
269
270    #[test]
271    fn normalized_color_key_matches_equivalent_inputs() {
272        let white = normalized_color_key("white").expect("white");
273        assert_eq!(white, normalized_color_key("#fff").expect("#fff"));
274        assert_eq!(white, normalized_color_key("#ffffff").expect("#ffffff"));
275        assert_eq!(
276            white,
277            normalized_color_key("rgb(255 255 255)").expect("rgb")
278        );
279        assert_eq!(white, normalized_color_key("hsl(0 0% 100%)").expect("hsl"));
280    }
281
282    #[test]
283    fn normalized_color_key_preserves_alpha() {
284        let translucent = normalized_color_key("rgba(255, 255, 255, 0.5)").expect("rgba");
285        assert_eq!(
286            translucent,
287            normalized_color_key("#ffffff80").expect("hex alpha")
288        );
289        assert_ne!(translucent, normalized_color_key("white").expect("white"));
290    }
291
292    #[test]
293    fn generate_color_presentations_formats_output() {
294        let range = Range::new(Position::new(0, 0), Position::new(0, 4));
295        let color = Color {
296            red: 1.0,
297            green: 0.0,
298            blue: 0.5,
299            alpha: 1.0,
300        };
301        let presentations = generate_color_presentations(color, range);
302        assert_eq!(presentations.len(), 3);
303        assert!(presentations[0].label.starts_with('#'));
304        assert!(presentations[1].label.starts_with("rgb("));
305        assert!(presentations[2].label.starts_with("hsl("));
306
307        let color = Color {
308            red: 1.0,
309            green: 0.0,
310            blue: 0.0,
311            alpha: 0.5,
312        };
313        let presentations = generate_color_presentations(color, range);
314        assert_eq!(presentations.len(), 3);
315        assert!(presentations[0].label.starts_with('#'));
316        assert!(presentations[1].label.starts_with("rgba("));
317        assert!(presentations[2].label.starts_with("hsla("));
318    }
319
320    #[test]
321    fn format_color_hex_opaque_and_transparent() {
322        // Opaque color (alpha = 255)
323        let color = Color {
324            red: 0.0,
325            green: 0.5,
326            blue: 1.0,
327            alpha: 1.0,
328        };
329        let hex = format_color_as_hex(color);
330        assert_eq!(hex, "#0080ff");
331
332        // Transparent color (alpha < 255)
333        let color = Color {
334            red: 1.0,
335            green: 0.0,
336            blue: 0.0,
337            alpha: 0.5,
338        };
339        let hex = format_color_as_hex(color);
340        assert_eq!(hex, "#ff000080");
341    }
342
343    #[test]
344    fn format_color_rgb_with_alpha() {
345        let color = Color {
346            red: 0.5,
347            green: 0.5,
348            blue: 0.5,
349            alpha: 1.0,
350        };
351        let rgb = format_color_as_rgb(color);
352        assert_eq!(rgb, "rgb(128, 128, 128)");
353
354        let color = Color {
355            red: 1.0,
356            green: 0.0,
357            blue: 0.0,
358            alpha: 0.75,
359        };
360        let rgba = format_color_as_rgb(color);
361        assert_eq!(rgba, "rgba(255, 0, 0, 0.75)");
362    }
363
364    #[test]
365    fn format_color_hsl_with_alpha() {
366        let color = Color {
367            red: 1.0,
368            green: 0.0,
369            blue: 0.0,
370            alpha: 1.0,
371        };
372        let hsl = format_color_as_hsl(color);
373        assert!(hsl.starts_with("hsl("));
374        assert!(hsl.contains("0,") || hsl.contains("360,")); // Red hue
375
376        let color = Color {
377            red: 0.0,
378            green: 0.5,
379            blue: 1.0,
380            alpha: 0.5,
381        };
382        let hsla = format_color_as_hsl(color);
383        assert!(hsla.starts_with("hsla("));
384        assert!(hsla.contains("0.50"));
385    }
386
387    #[test]
388    fn rgb_to_hsl_conversion() {
389        // Pure red
390        let (h, s, l) = rgb_to_hsl(1.0, 0.0, 0.0);
391        assert!(approx_eq(h, 0.0));
392        assert!(approx_eq(s, 1.0));
393        assert!(approx_eq(l, 0.5));
394
395        // Pure green
396        let (h, s, l) = rgb_to_hsl(0.0, 1.0, 0.0);
397        assert!(approx_eq(h, 1.0 / 3.0));
398        assert!(approx_eq(s, 1.0));
399        assert!(approx_eq(l, 0.5));
400
401        // Gray (no saturation)
402        let (_h, s, l) = rgb_to_hsl(0.5, 0.5, 0.5);
403        assert!(approx_eq(s, 0.0));
404        assert!(approx_eq(l, 0.5));
405    }
406
407    #[test]
408    fn parse_color_edge_cases() {
409        // Invalid colors should return None
410        assert!(parse_color("not-a-color").is_none());
411        assert!(parse_color("").is_none());
412        assert!(parse_color("rgb(999, 999, 999)").is_some()); // Clamped by parser
413
414        // Named colors
415        assert!(parse_color("rebeccapurple").is_some());
416        assert!(parse_color("aliceblue").is_some());
417
418        // Transparent keyword
419        let color = parse_color("transparent").expect("transparent");
420        assert!(approx_eq(color.alpha, 0.0));
421    }
422
423    #[test]
424    fn parse_color_rejects_nonfinite_channels_and_clamps_alpha() {
425        for value in [
426            "#gggggg",
427            "rgb(NaN, 0, 0)",
428            "rgb(inf, 0, 0)",
429            "rgb(infinity, 0, 0)",
430            "rgb(1e999, 0, 0)",
431            "hsl(0, NaN%, 50%)",
432            "hsl(infdeg, 50%, 50%)",
433            "hsl(infDEG, 50%, 50%)",
434            "hsl(1e999TURN, 50%, 50%)",
435            "hwb(0 0% 0% / NaN)",
436            "hwb(0 0% 0% / InFiNiTy)",
437        ] {
438            assert!(parse_color(value).is_none(), "{value}");
439        }
440
441        let color = parse_color("hwb(0 0% 0% / 2)").expect("finite HWB color");
442        assert!(approx_eq(color.alpha, 1.0));
443        for channel in [color.red, color.green, color.blue, color.alpha] {
444            assert!(channel.is_finite());
445            assert!((0.0..=1.0).contains(&channel));
446        }
447    }
448
449    #[test]
450    fn color_clamping() {
451        // Test that colors are properly clamped to [0, 1]
452        let color = Color {
453            red: 1.5,
454            green: -0.5,
455            blue: 0.5,
456            alpha: 2.0,
457        };
458
459        let hex = format_color_as_hex(color);
460        assert!(hex.starts_with('#'));
461
462        let rgb = format_color_as_rgb(color);
463        assert!(rgb.contains("255")); // Red clamped to max
464        assert!(rgb.contains("0")); // Green clamped to min
465    }
466}