Skip to main content

makeover/
literals.rs

1//! Finding colour written into a stylesheet rather than taken from a token.
2//!
3//! A colour an app writes itself is a colour no floor reaches: makeover lifts
4//! its own inks until they read on every ground, and a literal is whatever it
5//! was typed as, under every theme. A build script runs this over the sheets
6//! people edit and refuses what it finds.
7
8/// A colour written as a literal: the line it is on, counted from 1, and the
9/// declaration that holds it.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ColourLiteral {
12    pub line: usize,
13    pub declaration: String,
14}
15
16impl std::fmt::Display for ColourLiteral {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "line {}: {}", self.line, self.declaration)
19    }
20}
21
22/// Every declaration in `css` whose value writes a colour: a hex colour, a
23/// colour function (`rgb()`, `oklch()` and the rest), or a named colour in a
24/// property that takes one.
25///
26/// A mix or a relative colour built only from tokens has no literal in it and
27/// passes, and so do `transparent`, `currentColor` and the system colours,
28/// which name no colour of their own. Selectors, comments, strings and `url()`
29/// are not read, so `#add` as an id or a hex digit in a data URI is not a
30/// finding.
31pub fn colour_literals(css: &str) -> Vec<ColourLiteral> {
32    crate::look::sheet_declarations(css)
33        .into_iter()
34        .filter(|d| writes_colour(&d.property, &d.value))
35        .map(|d| ColourLiteral {
36            line: d.line,
37            declaration: d.text,
38        })
39        .collect()
40}
41
42/// Whether a declaration of `property` (lowercase) with `value` writes a colour.
43pub(crate) fn writes_colour(property: &str, value: &str) -> bool {
44    let takes_named = property.starts_with("--")
45        || COLOUR_PROPERTIES
46            .iter()
47            .any(|p| property == *p || property.starts_with(&format!("{p}-")));
48    has_hex(value) || has_colour_function(value) || takes_named && has_named(value)
49}
50
51/// Properties whose value a named colour can be, by stem: `border` covers
52/// `border-left-color` and the shorthand alike.
53const COLOUR_PROPERTIES: &[&str] = &[
54    "color",
55    "background",
56    "border",
57    "outline",
58    "box-shadow",
59    "text-shadow",
60    "text-decoration",
61    "text-emphasis",
62    "column-rule",
63    "fill",
64    "stroke",
65    "caret-color",
66    "accent-color",
67    "scrollbar-color",
68    "stop-color",
69    "flood-color",
70    "lighting-color",
71];
72
73fn has_hex(value: &str) -> bool {
74    let bytes = value.as_bytes();
75    bytes.iter().enumerate().any(|(i, &b)| {
76        if b != b'#' {
77            return false;
78        }
79        let digits = bytes[i + 1..]
80            .iter()
81            .take_while(|c| c.is_ascii_alphanumeric() || **c == b'-' || **c == b'_')
82            .count();
83        matches!(digits, 3 | 4 | 6 | 8)
84            && bytes[i + 1..i + 1 + digits]
85                .iter()
86                .all(u8::is_ascii_hexdigit)
87    })
88}
89
90/// The functions that write a colour from channels.
91const COLOUR_FUNCTIONS: &[&str] = &[
92    "rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color",
93];
94
95fn has_colour_function(value: &str) -> bool {
96    words(value).any(|(word, next)| {
97        next == Some('(')
98            && COLOUR_FUNCTIONS
99                .iter()
100                .any(|f| word.eq_ignore_ascii_case(f))
101    })
102}
103
104fn has_named(value: &str) -> bool {
105    words(value).any(|(word, next)| {
106        next != Some('(')
107            && NAMED_COLOURS
108                .binary_search(&word.to_ascii_lowercase().as_str())
109                .is_ok()
110    })
111}
112
113/// Each identifier in `value` with the character after it. A custom property's
114/// name is one identifier, `--content-on-black`, so a colour word inside it is
115/// not read as a colour.
116fn words(value: &str) -> impl Iterator<Item = (&str, Option<char>)> {
117    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
118    let mut rest = value;
119    std::iter::from_fn(move || {
120        let start = rest.find(is_ident)?;
121        let tail = &rest[start..];
122        let len = tail.find(|c: char| !is_ident(c)).unwrap_or(tail.len());
123        let word = &tail[..len];
124        let next = tail[len..].chars().next();
125        rest = &tail[len..];
126        Some((word, next))
127    })
128    .filter(|(word, _)| !word.starts_with("--") && !word.starts_with(|c: char| c.is_ascii_digit()))
129}
130
131/// The CSS named colours, sorted. `transparent`, `currentcolor` and the system
132/// colours are not here: they name no colour of their own.
133const NAMED_COLOURS: &[&str] = &[
134    "aliceblue",
135    "antiquewhite",
136    "aqua",
137    "aquamarine",
138    "azure",
139    "beige",
140    "bisque",
141    "black",
142    "blanchedalmond",
143    "blue",
144    "blueviolet",
145    "brown",
146    "burlywood",
147    "cadetblue",
148    "chartreuse",
149    "chocolate",
150    "coral",
151    "cornflowerblue",
152    "cornsilk",
153    "crimson",
154    "cyan",
155    "darkblue",
156    "darkcyan",
157    "darkgoldenrod",
158    "darkgray",
159    "darkgreen",
160    "darkgrey",
161    "darkkhaki",
162    "darkmagenta",
163    "darkolivegreen",
164    "darkorange",
165    "darkorchid",
166    "darkred",
167    "darksalmon",
168    "darkseagreen",
169    "darkslateblue",
170    "darkslategray",
171    "darkslategrey",
172    "darkturquoise",
173    "darkviolet",
174    "deeppink",
175    "deepskyblue",
176    "dimgray",
177    "dimgrey",
178    "dodgerblue",
179    "firebrick",
180    "floralwhite",
181    "forestgreen",
182    "fuchsia",
183    "gainsboro",
184    "ghostwhite",
185    "gold",
186    "goldenrod",
187    "gray",
188    "green",
189    "greenyellow",
190    "grey",
191    "honeydew",
192    "hotpink",
193    "indianred",
194    "indigo",
195    "ivory",
196    "khaki",
197    "lavender",
198    "lavenderblush",
199    "lawngreen",
200    "lemonchiffon",
201    "lightblue",
202    "lightcoral",
203    "lightcyan",
204    "lightgoldenrodyellow",
205    "lightgray",
206    "lightgreen",
207    "lightgrey",
208    "lightpink",
209    "lightsalmon",
210    "lightseagreen",
211    "lightskyblue",
212    "lightslategray",
213    "lightslategrey",
214    "lightsteelblue",
215    "lightyellow",
216    "lime",
217    "limegreen",
218    "linen",
219    "magenta",
220    "maroon",
221    "mediumaquamarine",
222    "mediumblue",
223    "mediumorchid",
224    "mediumpurple",
225    "mediumseagreen",
226    "mediumslateblue",
227    "mediumspringgreen",
228    "mediumturquoise",
229    "mediumvioletred",
230    "midnightblue",
231    "mintcream",
232    "mistyrose",
233    "moccasin",
234    "navajowhite",
235    "navy",
236    "oldlace",
237    "olive",
238    "olivedrab",
239    "orange",
240    "orangered",
241    "orchid",
242    "palegoldenrod",
243    "palegreen",
244    "paleturquoise",
245    "palevioletred",
246    "papayawhip",
247    "peachpuff",
248    "peru",
249    "pink",
250    "plum",
251    "powderblue",
252    "purple",
253    "rebeccapurple",
254    "red",
255    "rosybrown",
256    "royalblue",
257    "saddlebrown",
258    "salmon",
259    "sandybrown",
260    "seagreen",
261    "seashell",
262    "sienna",
263    "silver",
264    "skyblue",
265    "slateblue",
266    "slategray",
267    "slategrey",
268    "snow",
269    "springgreen",
270    "steelblue",
271    "tan",
272    "teal",
273    "thistle",
274    "tomato",
275    "turquoise",
276    "violet",
277    "wheat",
278    "white",
279    "whitesmoke",
280    "yellow",
281    "yellowgreen",
282];
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn lines(css: &str) -> Vec<usize> {
289        colour_literals(css).iter().map(|f| f.line).collect()
290    }
291
292    #[test]
293    fn the_named_colours_are_sorted_for_the_search() {
294        assert!(NAMED_COLOURS.windows(2).all(|w| w[0] < w[1]));
295    }
296
297    #[test]
298    fn each_way_of_writing_a_colour_is_found() {
299        let css = ".a { color: #fff; }\n\
300                   .b { background: rgba(0, 0, 0, 0.5); }\n\
301                   .c { border-left: 1px solid white; }\n\
302                   :root { --x: oklch(0.5 0.1 200); }\n\
303                   .d { box-shadow: 0 0 0 1px color-mix(in oklch, var(--danger) 90%, black); }\n";
304        assert_eq!(lines(css), vec![1, 2, 3, 4, 5]);
305    }
306
307    #[test]
308    fn colour_built_from_tokens_passes() {
309        let css = ".a { color: var(--danger-text); }\n\
310                   .b { background: color-mix(in oklch, var(--content) 85%, var(--surface-page)); }\n\
311                   .c { border-color: transparent; outline-color: currentColor; }\n\
312                   .d { background: Canvas; }\n\
313                   .e { background: oklch(from var(--action) l c h); }\n";
314        // A relative colour is a function call, and its channels are the
315        // token's, but the function is where an author can write a literal.
316        assert_eq!(lines(css), vec![5]);
317    }
318
319    #[test]
320    fn selectors_comments_strings_and_urls_are_not_read() {
321        let css = "/* #fff and white are fine in prose */\n\
322                   #add, #bad > .face { white-space: nowrap; }\n\
323                   .a::before { content: \"#fff\"; }\n\
324                   .b { background-image: url(data:image/svg+xml,%23fff); mask: url(#fade); }\n\
325                   .c { font-family: \"White Rabbit\", Georgia; }\n\
326                   .d { transition: color var(--timing-fast); }\n";
327        assert!(
328            colour_literals(css).is_empty(),
329            "{:?}",
330            colour_literals(css)
331        );
332    }
333
334    #[test]
335    fn a_comment_before_a_declaration_is_not_quoted() {
336        let found = colour_literals(":root {\n  /* the page */\n  --surface-page: #ddd6c9;\n}");
337        assert_eq!(found.len(), 1);
338        assert_eq!(found[0].line, 3);
339        assert_eq!(found[0].declaration, "--surface-page: #ddd6c9");
340    }
341
342    #[test]
343    fn a_colour_word_inside_a_token_name_is_not_a_colour() {
344        assert_eq!(
345            colour_literals(".a { color: var(--content-on-black); }"),
346            Vec::new()
347        );
348    }
349
350    #[test]
351    fn the_finding_quotes_its_declaration_and_line() {
352        let found = colour_literals("\n\n.a {\n    color:   #c0392b;\n}\n");
353        assert_eq!(
354            found,
355            vec![ColourLiteral {
356                line: 4,
357                declaration: "color: #c0392b".to_owned()
358            }]
359        );
360    }
361}