1use 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
86pub 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
100pub 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
151pub 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
282pub 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
294pub fn palette_to_css(colors: &[String]) -> String {
297 format!("linear-gradient(to right, {})", colors.join(", "))
298}
299
300#[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#[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#[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#[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
451pub 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
482pub 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
493pub 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}