1#[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
22pub 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
42pub(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
51const 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
90const 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
113fn 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
131const 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 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}