Skip to main content

perspective_viewer/utils/
css.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
19pub struct GradientStopSpec {
20    pub color: String,
21    pub offset: f64,
22}
23
24pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
25    for stop in &mut stops {
26        stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
27    }
28
29    stops.sort_by(|a, b| {
30        a.offset
31            .partial_cmp(&b.offset)
32            .unwrap_or(std::cmp::Ordering::Equal)
33    });
34
35    stops
36}
37
38pub const USER_VAR_PREFIX: &str = "--psp-user--";
39
40#[derive(Clone, Debug, PartialEq)]
41pub struct NamedValue {
42    pub name: String,
43    pub value: String,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub enum CssKind {
48    Gradient,
49    Palette,
50    Color,
51}
52
53impl CssKind {
54    pub const ALL: [CssKind; 3] = [CssKind::Gradient, CssKind::Palette, CssKind::Color];
55
56    pub fn var_prefix(self) -> &'static str {
57        match self {
58            CssKind::Gradient => "--psp-user--gradient-",
59            CssKind::Palette => "--psp-user--palette-",
60            CssKind::Color => "--psp-user--color-",
61        }
62    }
63
64    pub fn of_var(name: &str) -> Option<CssKind> {
65        CssKind::ALL
66            .into_iter()
67            .find(|kind| name.starts_with(kind.var_prefix()))
68    }
69
70    pub fn canonicalize(self, src: &str) -> Result<String, String> {
71        match self {
72            CssKind::Gradient => CssGradient::parse(src).map(|x| x.to_css()),
73            CssKind::Palette => CssPalette::parse(src).map(|x| x.to_css()),
74            CssKind::Color => CssColor::parse(src).map(|x| x.to_css()),
75        }
76    }
77
78    pub fn short_name(self, var_name: &str) -> String {
79        var_name
80            .strip_prefix(self.var_prefix())
81            .unwrap_or(var_name)
82            .to_owned()
83    }
84}
85
86/// The property name a whole-value `var(--psp-user--…)` reference names, or
87/// `None` when `src` is not one.
88pub fn parse_var_ref(src: &str) -> Option<&str> {
89    let inner = src.trim().strip_prefix("var(")?.strip_suffix(')')?.trim();
90    if !inner.starts_with(USER_VAR_PREFIX)
91        || inner.len() == USER_VAR_PREFIX.len()
92        || inner.contains(|c: char| c.is_whitespace() || c == ',' || c == ')')
93    {
94        return None;
95    }
96
97    Some(inner)
98}
99
100/// `var(name)`.
101pub fn format_var_ref(name: &str) -> String {
102    format!("var({name})")
103}
104
105fn parse_kind_var(src: &str, kind: CssKind) -> Result<Option<String>, String> {
106    let Some(name) = parse_var_ref(src) else {
107        return Ok(None);
108    };
109
110    if name.starts_with(kind.var_prefix()) {
111        Ok(Some(name.to_owned()))
112    } else {
113        Err(format!(
114            "`{src}` is not a {} reference (expected `var({}…)`)",
115            kind_label(kind),
116            kind.var_prefix()
117        ))
118    }
119}
120
121fn kind_label(kind: CssKind) -> &'static str {
122    match kind {
123        CssKind::Gradient => "gradient",
124        CssKind::Palette => "palette",
125        CssKind::Color => "color",
126    }
127}
128
129fn hex_channel(src: &str) -> Result<u8, String> {
130    u8::from_str_radix(src, 16).map_err(|_| format!("invalid hex color `#{src}`"))
131}
132
133fn channel_from_token(token: &str) -> Result<u8, String> {
134    let token = token.trim();
135    let value = match token.strip_suffix('%') {
136        Some(pct) => {
137            pct.trim()
138                .parse::<f64>()
139                .map_err(|_| format!("invalid color channel `{token}`"))?
140                / 100.0
141                * 255.0
142        },
143        None => token
144            .parse::<f64>()
145            .map_err(|_| format!("invalid color channel `{token}`"))?,
146    };
147
148    Ok(value.round().clamp(0.0, 255.0) as u8)
149}
150
151/// A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` or
152/// `rgba()` — normalized to lowercase `#rrggbb`.
153pub fn normalize_css_color(src: &str) -> Result<String, String> {
154    let src = src.trim();
155    if let Some(hex) = src.strip_prefix('#') {
156        let (r, g, b) = match hex.len() {
157            3 | 4 => {
158                let d = |i: usize| hex_channel(&hex[i..i + 1]).map(|x| x * 17);
159                (d(0)?, d(1)?, d(2)?)
160            },
161            6 | 8 => (
162                hex_channel(&hex[0..2])?,
163                hex_channel(&hex[2..4])?,
164                hex_channel(&hex[4..6])?,
165            ),
166            _ => return Err(format!("invalid hex color `{src}`")),
167        };
168
169        return Ok(format!("#{r:02x}{g:02x}{b:02x}"));
170    }
171
172    let lower = src.to_ascii_lowercase();
173    let body = lower
174        .strip_prefix("rgba(")
175        .or_else(|| lower.strip_prefix("rgb("))
176        .and_then(|x| x.strip_suffix(')'))
177        .ok_or_else(|| format!("unsupported color `{src}`"))?;
178
179    let body = body.split('/').next().unwrap_or("");
180    let tokens: Vec<&str> = body
181        .split(|c: char| c == ',' || c.is_whitespace())
182        .filter(|x| !x.is_empty())
183        .collect();
184
185    if tokens.len() < 3 || tokens.len() > 4 {
186        return Err(format!("unsupported color `{src}`"));
187    }
188
189    Ok(format!(
190        "#{:02x}{:02x}{:02x}",
191        channel_from_token(tokens[0])?,
192        channel_from_token(tokens[1])?,
193        channel_from_token(tokens[2])?
194    ))
195}
196
197fn split_entries(body: &str) -> Vec<&str> {
198    let mut parts = vec![];
199    let mut depth = 0i32;
200    let mut start = 0;
201    for (i, ch) in body.char_indices() {
202        match ch {
203            '(' => depth += 1,
204            ')' => depth -= 1,
205            ',' if depth == 0 => {
206                parts.push(&body[start..i]);
207                start = i + 1;
208            },
209            _ => {},
210        }
211    }
212
213    parts.push(&body[start..]);
214    parts
215}
216
217fn is_direction_token(entry: &str) -> bool {
218    let entry = entry.trim().to_ascii_lowercase();
219    if entry.starts_with("to ") {
220        return true;
221    }
222
223    ["deg", "rad", "grad", "turn"].iter().any(|unit| {
224        entry
225            .strip_suffix(unit)
226            .map(|n| n.parse::<f64>().is_ok())
227            .unwrap_or(false)
228    })
229}
230
231fn tokenize_linear_gradient(src: &str) -> Result<Vec<(String, Option<f64>)>, String> {
232    let src = src.trim();
233    let lower = src.to_ascii_lowercase();
234    let body = lower
235        .strip_prefix("linear-gradient(")
236        .and_then(|x| x.strip_suffix(')'))
237        .ok_or_else(|| format!("expected `linear-gradient(…)`, got `{src}`"))?;
238
239    let entries = split_entries(body);
240    let mut out = vec![];
241    for (index, entry) in entries.iter().enumerate() {
242        let entry = entry.trim();
243        if entry.is_empty() {
244            return Err(format!("empty entry in `{src}`"));
245        }
246
247        if index == 0 && is_direction_token(entry) {
248            continue;
249        }
250
251        let (color, position) = match entry.rfind(|c: char| c.is_whitespace()) {
252            Some(at) if !entry[at..].contains(')') => {
253                let tail = entry[at..].trim();
254                if let Some(pct) = tail.strip_suffix('%') {
255                    let value = pct
256                        .parse::<f64>()
257                        .map_err(|_| format!("invalid stop position `{tail}`"))?;
258                    (entry[..at].trim(), Some(value / 100.0))
259                } else if tail.starts_with(|c: char| c.is_ascii_digit() || c == '.' || c == '-') {
260                    return Err(format!(
261                        "unsupported stop position `{tail}` (only `%` is accepted)"
262                    ));
263                } else {
264                    (entry, None)
265                }
266            },
267            _ => (entry, None),
268        };
269
270        out.push((normalize_css_color(color)?, position));
271    }
272
273    Ok(out)
274}
275
276fn format_percent(offset: f64) -> String {
277    let text = format!("{:.1}", offset * 100.0);
278    let text = text.strip_suffix(".0").unwrap_or(&text).to_owned();
279    format!("{text}%")
280}
281
282/// The canonical gradient string for `stops`: `linear-gradient(to right,
283/// #rrggbb P%, …)` with every position explicit.
284pub fn gradient_to_css(stops: &[GradientStopSpec]) -> String {
285    let body = canonicalize_gradient_stops(stops.to_vec())
286        .iter()
287        .map(|stop| format!("{} {}", stop.color, format_percent(stop.offset)))
288        .collect::<Vec<_>>()
289        .join(", ");
290
291    format!("linear-gradient(to right, {body})")
292}
293
294/// The canonical palette string for `colors`: `linear-gradient(to right,
295/// #rrggbb, …)` with no positions.
296pub fn palette_to_css(colors: &[String]) -> String {
297    format!("linear-gradient(to right, {})", colors.join(", "))
298}
299
300/// A serialized `ControlSpec::GradientStops` value.
301#[derive(Clone, Debug, PartialEq)]
302pub enum CssGradient {
303    Literal(Vec<GradientStopSpec>),
304    Var(String),
305}
306
307impl CssGradient {
308    pub fn parse(src: &str) -> Result<Self, String> {
309        if let Some(name) = parse_kind_var(src, CssKind::Gradient)? {
310            return Ok(CssGradient::Var(name));
311        }
312
313        let entries = tokenize_linear_gradient(src)?;
314        if entries.len() < 2 {
315            return Err(format!("a gradient needs at least 2 stops: `{src}`"));
316        }
317
318        let mut offsets: Vec<Option<f64>> = entries.iter().map(|(_, p)| *p).collect();
319        let last = offsets.len() - 1;
320        offsets[0].get_or_insert(0.0);
321        offsets[last].get_or_insert(1.0);
322        let mut i = 1;
323        while i < last {
324            if offsets[i].is_some() {
325                i += 1;
326                continue;
327            }
328
329            let mut j = i + 1;
330            while offsets[j].is_none() {
331                j += 1;
332            }
333
334            let before = offsets[i - 1].unwrap();
335            let after = offsets[j].unwrap();
336            let span = (j - (i - 1)) as f64;
337            for (k, slot) in offsets.iter_mut().enumerate().take(j).skip(i) {
338                *slot = Some(before + ((k - (i - 1)) as f64 / span) * (after - before));
339            }
340
341            i = j;
342        }
343
344        let stops = entries
345            .into_iter()
346            .zip(offsets)
347            .map(|((color, _), offset)| GradientStopSpec {
348                color,
349                offset: offset.unwrap(),
350            })
351            .collect();
352
353        Ok(CssGradient::Literal(canonicalize_gradient_stops(stops)))
354    }
355
356    pub fn to_css(&self) -> String {
357        match self {
358            CssGradient::Literal(stops) => gradient_to_css(stops),
359            CssGradient::Var(name) => format_var_ref(name),
360        }
361    }
362}
363
364/// A serialized `ControlSpec::Palette` value.
365#[derive(Clone, Debug, PartialEq)]
366pub enum CssPalette {
367    Literal(Vec<String>),
368    Var(String),
369}
370
371impl CssPalette {
372    pub fn parse(src: &str) -> Result<Self, String> {
373        if let Some(name) = parse_kind_var(src, CssKind::Palette)? {
374            return Ok(CssPalette::Var(name));
375        }
376
377        let entries = tokenize_linear_gradient(src)?;
378        if entries.is_empty() {
379            return Err(format!("a palette needs at least 1 color: `{src}`"));
380        }
381
382        let mut colors = Vec::with_capacity(entries.len());
383        for (color, position) in entries {
384            if position.is_some() {
385                return Err(format!(
386                    "palette entries must not carry positions (a positioned `linear-gradient()` \
387                     is a gradient, not a palette): `{src}`"
388                ));
389            }
390
391            colors.push(color);
392        }
393
394        Ok(CssPalette::Literal(colors))
395    }
396
397    pub fn to_css(&self) -> String {
398        match self {
399            CssPalette::Literal(colors) => palette_to_css(colors),
400            CssPalette::Var(name) => format_var_ref(name),
401        }
402    }
403}
404
405/// A serialized `ControlSpec::Color` value.
406#[derive(Clone, Debug, PartialEq)]
407pub enum CssColor {
408    Literal(String),
409    Var(String),
410}
411
412impl CssColor {
413    pub fn parse(src: &str) -> Result<Self, String> {
414        if let Some(name) = parse_kind_var(src, CssKind::Color)? {
415            return Ok(CssColor::Var(name));
416        }
417
418        normalize_css_color(src).map(CssColor::Literal)
419    }
420
421    pub fn to_css(&self) -> String {
422        match self {
423            CssColor::Literal(color) => color.clone(),
424            CssColor::Var(name) => format_var_ref(name),
425        }
426    }
427}
428
429pub fn canonicalize_css_gradient(src: &str) -> Result<String, String> {
430    CssKind::Gradient.canonicalize(src)
431}
432
433pub fn canonicalize_css_palette(src: &str) -> Result<String, String> {
434    CssKind::Palette.canonicalize(src)
435}
436
437pub fn canonicalize_css_color(src: &str) -> Result<String, String> {
438    CssKind::Color.canonicalize(src)
439}
440
441/// One CSS literal stored in a panel's `columns_config`: its column, key,
442/// schema kind and canonical value — the unit of "in use".
443#[derive(Clone, Debug, PartialEq)]
444pub struct CssLiteralUse {
445    pub column: String,
446    pub key: String,
447    pub kind: CssKind,
448    pub literal: String,
449}
450
451/// The workspace palette: `restored` unioned with every in-use literal, each
452/// named deterministically.
453pub fn assign_palette_names(
454    restored: &BTreeMap<String, String>,
455    host: &[NamedValue],
456    in_use: &[(CssKind, String)],
457    is_taken: &dyn Fn(&str) -> bool,
458) -> BTreeMap<String, String> {
459    let mut set = restored.clone();
460    for (kind, literal) in in_use {
461        if palette_name_for(&set, *kind, literal).is_some() {
462            continue;
463        }
464
465        let name = host
466            .iter()
467            .find(|entry| entry.name.starts_with(kind.var_prefix()) && entry.value == *literal)
468            .map(|entry| entry.name.clone())
469            .unwrap_or_else(|| {
470                (1..)
471                    .map(|n| format!("{}{n}", kind.var_prefix()))
472                    .find(|name| !set.contains_key(name) && !is_taken(name))
473                    .unwrap()
474            });
475
476        set.insert(name, literal.clone());
477    }
478
479    set
480}
481
482/// The name under which `set` holds `literal` as a value of `kind`.
483pub fn palette_name_for(
484    set: &BTreeMap<String, String>,
485    kind: CssKind,
486    literal: &str,
487) -> Option<String> {
488    set.iter()
489        .find(|(name, value)| name.starts_with(kind.var_prefix()) && *value == literal)
490        .map(|(name, _)| name.clone())
491}
492
493/// Replace every whole-value `var(--psp-user--…)` string in one column's config
494/// entry with the literal `lookup` resolves it to, removing and reporting keys
495/// that do not resolve.
496pub fn resolve_css_refs(
497    entry: &mut serde_json::Map<String, Value>,
498    lookup: &dyn Fn(&str) -> Option<String>,
499) -> Vec<(String, String)> {
500    let mut dropped = vec![];
501    let refs: Vec<(String, String)> = entry
502        .iter()
503        .filter_map(|(key, value)| {
504            let name = parse_var_ref(value.as_str()?)?;
505            Some((key.clone(), name.to_owned()))
506        })
507        .collect();
508
509    for (key, name) in refs {
510        let resolved = CssKind::of_var(&name)
511            .and_then(|kind| lookup(&name).and_then(|raw| kind.canonicalize(&raw).ok()));
512
513        match resolved {
514            Some(literal) => {
515                entry.insert(key, Value::String(literal));
516            },
517            None => {
518                entry.remove(&key);
519                dropped.push((key, name));
520            },
521        }
522    }
523
524    dropped
525}
526
527#[cfg(test)]
528mod tests {
529    use serde_json::json;
530
531    use super::*;
532
533    fn stop(color: &str, offset: f64) -> GradientStopSpec {
534        GradientStopSpec {
535            color: color.to_owned(),
536            offset,
537        }
538    }
539
540    #[test]
541    fn colors_normalize_to_lowercase_hex() {
542        assert_eq!(normalize_css_color("#ABC").unwrap(), "#aabbcc");
543        assert_eq!(normalize_css_color("#abcd").unwrap(), "#aabbcc");
544        assert_eq!(normalize_css_color(" #FF7F0E ").unwrap(), "#ff7f0e");
545        assert_eq!(normalize_css_color("#ff7f0e80").unwrap(), "#ff7f0e");
546        assert_eq!(normalize_css_color("rgb(255, 127, 14)").unwrap(), "#ff7f0e");
547        assert_eq!(
548            normalize_css_color("rgba(255,127,14,0.5)").unwrap(),
549            "#ff7f0e"
550        );
551        assert_eq!(
552            normalize_css_color("rgb(255 127 14 / 50%)").unwrap(),
553            "#ff7f0e"
554        );
555        assert_eq!(
556            normalize_css_color("rgb(100%, 0%, 50%)").unwrap(),
557            "#ff0080"
558        );
559        assert_eq!(normalize_css_color("rgb(300, -5, 14)").unwrap(), "#ff000e");
560        assert!(normalize_css_color("red").is_err());
561        assert!(normalize_css_color("#12345").is_err());
562        assert!(normalize_css_color("hsl(0, 100%, 50%)").is_err());
563        assert!(normalize_css_color("#gggggg").is_err());
564        assert!(normalize_css_color("").is_err());
565    }
566
567    #[test]
568    fn var_refs_are_whole_value_and_namespaced() {
569        assert_eq!(
570            parse_var_ref("var(--psp-user--gradient-1)"),
571            Some("--psp-user--gradient-1")
572        );
573        assert_eq!(
574            parse_var_ref("  var( --psp-user--color-hot )  "),
575            Some("--psp-user--color-hot")
576        );
577        assert_eq!(parse_var_ref("var(--psp-charts--gradient)"), None);
578        assert_eq!(parse_var_ref("var(--psp-user--)"), None);
579        assert_eq!(parse_var_ref("var(--psp-user--gradient-1, red)"), None);
580        assert_eq!(parse_var_ref("#ff0000"), None);
581        assert_eq!(
582            parse_var_ref("linear-gradient(var(--psp-user--color-1), #fff)"),
583            None
584        );
585    }
586
587    #[test]
588    fn kind_is_carried_by_the_reference_prefix() {
589        assert_eq!(
590            CssKind::of_var("--psp-user--gradient-1"),
591            Some(CssKind::Gradient)
592        );
593        assert_eq!(
594            CssKind::of_var("--psp-user--palette-warm"),
595            Some(CssKind::Palette)
596        );
597        assert_eq!(CssKind::of_var("--psp-user--color-1"), Some(CssKind::Color));
598        assert_eq!(CssKind::of_var("--psp-user--other-1"), None);
599        assert_eq!(
600            CssKind::Palette.short_name("--psp-user--palette-warm"),
601            "warm"
602        );
603
604        assert_eq!(
605            CssGradient::parse("var(--psp-user--gradient-1)").unwrap(),
606            CssGradient::Var("--psp-user--gradient-1".to_owned())
607        );
608        assert!(CssGradient::parse("var(--psp-user--palette-1)").is_err());
609        assert!(CssPalette::parse("var(--psp-user--gradient-1)").is_err());
610        assert!(CssColor::parse("var(--psp-user--palette-1)").is_err());
611        assert_eq!(
612            CssColor::parse("var(--psp-user--color-1)")
613                .unwrap()
614                .to_css(),
615            "var(--psp-user--color-1)"
616        );
617
618        assert_eq!(
619            canonicalize_css_palette("var(--psp-user--palette-1)").unwrap(),
620            "var(--psp-user--palette-1)"
621        );
622    }
623
624    #[test]
625    fn gradient_round_trip_is_canonical_and_idempotent() {
626        let src = "linear-gradient(to right, #0366d6 0%, #ffffff 33.3%, #ff7f0e 100%)";
627        let canon = canonicalize_css_gradient(src).unwrap();
628        assert_eq!(canon, src);
629        assert_eq!(canonicalize_css_gradient(&canon).unwrap(), canon);
630
631        let CssGradient::Literal(stops) = CssGradient::parse(src).unwrap() else {
632            panic!("expected literal");
633        };
634
635        assert_eq!(stops, vec![
636            stop("#0366d6", 0.0),
637            stop("#ffffff", 0.333),
638            stop("#ff7f0e", 1.0),
639        ]);
640
641        let css = gradient_to_css(&[
642            stop("#222222", 1.7),
643            stop("#111111", 1.0 / 3.0),
644            stop("#000000", -0.5),
645        ]);
646
647        assert_eq!(
648            css,
649            "linear-gradient(to right, #000000 0%, #111111 33.3%, #222222 100%)"
650        );
651
652        assert_eq!(format_percent(0.375), "37.5%");
653        assert_eq!(format_percent(0.1), "10%");
654        assert_eq!(format_percent(1.0), "100%");
655    }
656
657    #[test]
658    fn gradient_accepts_loose_input() {
659        assert_eq!(
660            canonicalize_css_gradient("linear-gradient(#0366d6 0%, #ff7f0e 100%)").unwrap(),
661            "linear-gradient(to right, #0366d6 0%, #ff7f0e 100%)"
662        );
663
664        assert_eq!(
665            canonicalize_css_gradient("LINEAR-GRADIENT(90deg, RGB(3, 102, 214), #FFF, #ff7f0e)")
666                .unwrap(),
667            "linear-gradient(to right, #0366d6 0%, #ffffff 50%, #ff7f0e 100%)"
668        );
669
670        assert_eq!(
671            canonicalize_css_gradient(
672                "linear-gradient(to bottom, #000 10%, #111, #222, #333 70%, #444)"
673            )
674            .unwrap(),
675            "linear-gradient(to right, #000000 10%, #111111 30%, #222222 50%, #333333 70%, \
676             #444444 100%)"
677        );
678
679        assert_eq!(
680            canonicalize_css_gradient("linear-gradient(#fff 120%, #000 -5%)").unwrap(),
681            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
682        );
683    }
684
685    #[test]
686    fn gradient_rejects_malformed_input() {
687        assert!(canonicalize_css_gradient("#ff0000").is_err());
688        assert!(canonicalize_css_gradient("linear-gradient(#ff0000)").is_err());
689        assert!(canonicalize_css_gradient("linear-gradient(#ff0000 0px, #fff 10px)").is_err());
690        assert!(canonicalize_css_gradient("linear-gradient(red, blue)").is_err());
691        assert!(canonicalize_css_gradient("radial-gradient(#000, #fff)").is_err());
692        assert!(canonicalize_css_gradient("linear-gradient(#000, , #fff)").is_err());
693        assert!(canonicalize_css_gradient("").is_err());
694    }
695
696    #[test]
697    fn palette_round_trip_and_rules() {
698        let src = "linear-gradient(to right, #0366d6, #ff7f0e, #2ca02c)";
699        assert_eq!(canonicalize_css_palette(src).unwrap(), src);
700        assert_eq!(
701            CssPalette::parse(src).unwrap(),
702            CssPalette::Literal(vec![
703                "#0366d6".to_owned(),
704                "#ff7f0e".to_owned(),
705                "#2ca02c".to_owned()
706            ])
707        );
708
709        assert_eq!(
710            canonicalize_css_palette("linear-gradient(90deg, rgb(3,102,214), #FFF)").unwrap(),
711            "linear-gradient(to right, #0366d6, #ffffff)"
712        );
713
714        assert_eq!(
715            canonicalize_css_palette("linear-gradient(#abc)").unwrap(),
716            "linear-gradient(to right, #aabbcc)"
717        );
718
719        assert_eq!(
720            palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]),
721            "linear-gradient(to right, #000000, #ffffff)"
722        );
723    }
724
725    #[test]
726    fn palette_rejects_positions() {
727        let gradient = gradient_to_css(&[stop("#000000", 0.0), stop("#ffffff", 1.0)]);
728        assert!(CssPalette::parse(&gradient).is_err());
729        assert!(canonicalize_css_palette("linear-gradient(#000, #fff 50%)").is_err());
730        assert!(canonicalize_css_palette("linear-gradient()").is_err());
731        assert!(canonicalize_css_palette("#000000").is_err());
732
733        let palette = palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]);
734        assert_eq!(
735            canonicalize_css_gradient(&palette).unwrap(),
736            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
737        );
738    }
739
740    #[test]
741    fn color_canonicalizes_literals_and_passes_refs() {
742        assert_eq!(canonicalize_css_color("#ABC").unwrap(), "#aabbcc");
743        assert_eq!(
744            canonicalize_css_color("var(--psp-user--color-hot)").unwrap(),
745            "var(--psp-user--color-hot)"
746        );
747        assert!(canonicalize_css_color("var(--psp-user--gradient-1)").is_err());
748        assert!(canonicalize_css_color("linear-gradient(#000, #fff)").is_err());
749    }
750
751    #[test]
752    fn palette_set_unions_restored_and_in_use_with_stable_names() {
753        let mut restored = BTreeMap::new();
754        restored.insert(
755            "--psp-user--gradient-heat".to_owned(),
756            "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
757        );
758        restored.insert("--psp-user--color-unused".to_owned(), "#123456".to_owned());
759        let host = vec![NamedValue {
760            name: "--psp-user--palette-1".to_owned(),
761            value: "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
762        }];
763        let taken =
764            |name: &str| name == "--psp-user--gradient-1" || name == "--psp-user--palette-1";
765        let in_use = vec![
766            (
767                CssKind::Gradient,
768                "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
769            ),
770            (
771                CssKind::Gradient,
772                "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
773            ),
774            (
775                CssKind::Gradient,
776                "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
777            ),
778            (
779                CssKind::Palette,
780                "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
781            ),
782            (CssKind::Color, "#abcdef".to_owned()),
783        ];
784
785        let set = assign_palette_names(&restored, &host, &in_use, &taken);
786        assert_eq!(
787            set.iter()
788                .map(|(k, v)| (k.as_str(), v.as_str()))
789                .collect::<Vec<_>>(),
790            vec![
791                ("--psp-user--color-1", "#abcdef"),
792                ("--psp-user--color-unused", "#123456"),
793                (
794                    "--psp-user--gradient-2",
795                    "linear-gradient(to right, #111111 0%, #222222 100%)"
796                ),
797                (
798                    "--psp-user--gradient-heat",
799                    "linear-gradient(to right, #000000 0%, #ffffff 100%)"
800                ),
801                (
802                    "--psp-user--palette-1",
803                    "linear-gradient(to right, #ff0000, #00ff00)"
804                ),
805            ]
806        );
807
808        assert_eq!(
809            palette_name_for(
810                &set,
811                CssKind::Gradient,
812                "linear-gradient(to right, #111111 0%, #222222 100%)"
813            )
814            .as_deref(),
815            Some("--psp-user--gradient-2")
816        );
817        assert_eq!(palette_name_for(&set, CssKind::Gradient, "#abcdef"), None);
818
819        assert_eq!(assign_palette_names(&set, &host, &in_use, &taken), set);
820    }
821
822    #[test]
823    fn resolver_inlines_known_refs_and_drops_the_rest() {
824        let host = |name: &str| -> Option<String> {
825            match name {
826                "--psp-user--gradient-1" => Some("linear-gradient(#000, #fff)".to_owned()),
827                "--psp-user--palette-warm" => Some("linear-gradient(#f00, #ff0)".to_owned()),
828                "--psp-user--color-hot" => Some("rgb(255, 0, 0)".to_owned()),
829                "--psp-user--palette-bad" => Some("linear-gradient(#000 0%, #fff 100%)".to_owned()),
830                "--psp-user--color-empty" => Some("   ".to_owned()),
831                _ => None,
832            }
833        };
834
835        let mut entry = json!({
836            "gradient": "var(--psp-user--gradient-1)",
837            "palette": "var(--psp-user--palette-warm)",
838            "color": "var(--psp-user--color-hot)",
839            "bad_palette": "var(--psp-user--palette-bad)",
840            "missing": "var(--psp-user--gradient-missing)",
841            "empty": "var(--psp-user--color-empty)",
842            "unknown_kind": "var(--psp-user--other-1)",
843            "literal": "#123456",
844            "number": 3,
845        })
846        .as_object()
847        .unwrap()
848        .clone();
849
850        let mut dropped = resolve_css_refs(&mut entry, &host);
851        dropped.sort();
852        assert_eq!(dropped, vec![
853            (
854                "bad_palette".to_owned(),
855                "--psp-user--palette-bad".to_owned()
856            ),
857            ("empty".to_owned(), "--psp-user--color-empty".to_owned()),
858            (
859                "missing".to_owned(),
860                "--psp-user--gradient-missing".to_owned()
861            ),
862            ("unknown_kind".to_owned(), "--psp-user--other-1".to_owned()),
863        ]);
864
865        assert_eq!(
866            entry,
867            json!({
868                "gradient": "linear-gradient(to right, #000000 0%, #ffffff 100%)",
869                "palette": "linear-gradient(to right, #ff0000, #ffff00)",
870                "color": "#ff0000",
871                "literal": "#123456",
872                "number": 3,
873            })
874            .as_object()
875            .unwrap()
876            .clone()
877        );
878
879        assert!(entry.values().all(|v| {
880            v.as_str()
881                .map(|s| parse_var_ref(s).is_none())
882                .unwrap_or(true)
883        }));
884    }
885}