#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColourLiteral {
pub line: usize,
pub declaration: String,
}
impl std::fmt::Display for ColourLiteral {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.declaration)
}
}
pub fn colour_literals(css: &str) -> Vec<ColourLiteral> {
let text = blank_unread(css);
let mut found = Vec::new();
let mut start = 0;
for (at, c) in text.char_indices() {
if !matches!(c, '{' | '}' | ';') {
continue;
}
if c != '{' {
check_declaration(&text, start, at, &mut found);
}
start = at + 1;
}
check_declaration(&text, start, text.len(), &mut found);
found
}
fn check_declaration(text: &str, start: usize, end: usize, found: &mut Vec<ColourLiteral>) {
let segment = &text[start..end];
let Some(colon) = segment.find(':') else {
return;
};
let property = segment[..colon].trim().to_ascii_lowercase();
if property.is_empty() || property.contains(char::is_whitespace) {
return;
}
let value = &segment[colon + 1..];
if !writes_colour(&property, value) {
return;
}
let lead = segment.len() - segment.trim_start().len();
let line = text[..start + lead].matches('\n').count() + 1;
let declaration = segment.split_whitespace().collect::<Vec<_>>().join(" ");
found.push(ColourLiteral { line, declaration });
}
pub(crate) fn writes_colour(property: &str, value: &str) -> bool {
let takes_named = property.starts_with("--")
|| COLOUR_PROPERTIES
.iter()
.any(|p| property == *p || property.starts_with(&format!("{p}-")));
has_hex(value) || has_colour_function(value) || takes_named && has_named(value)
}
pub(crate) fn blank_unread(css: &str) -> String {
let bytes = css.as_bytes();
let mut out = bytes.to_vec();
let mut i = 0;
let blank = |out: &mut Vec<u8>, from: usize, to: usize| {
for b in &mut out[from..to] {
if *b != b'\n' {
*b = b' ';
}
}
};
while i < bytes.len() {
if bytes[i..].starts_with(b"/*") {
let end = css[i + 2..]
.find("*/")
.map_or(bytes.len(), |e| i + 2 + e + 2);
blank(&mut out, i, end);
i = end;
} else if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
let mut j = i + 1;
while j < bytes.len() && bytes[j] != quote {
j += if bytes[j] == b'\\' { 2 } else { 1 };
}
let end = (j + 1).min(bytes.len());
blank(&mut out, i, end);
i = end;
} else if bytes[i..].len() >= 4 && bytes[i..i + 4].eq_ignore_ascii_case(b"url(") {
let end = css[i..].find(')').map_or(bytes.len(), |e| i + e + 1);
blank(&mut out, i, end);
i = end;
} else {
i += 1;
}
}
String::from_utf8(out).unwrap_or_default()
}
const COLOUR_PROPERTIES: &[&str] = &[
"color",
"background",
"border",
"outline",
"box-shadow",
"text-shadow",
"text-decoration",
"text-emphasis",
"column-rule",
"fill",
"stroke",
"caret-color",
"accent-color",
"scrollbar-color",
"stop-color",
"flood-color",
"lighting-color",
];
fn has_hex(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
if b != b'#' {
return false;
}
let digits = bytes[i + 1..]
.iter()
.take_while(|c| c.is_ascii_alphanumeric() || **c == b'-' || **c == b'_')
.count();
matches!(digits, 3 | 4 | 6 | 8)
&& bytes[i + 1..i + 1 + digits]
.iter()
.all(u8::is_ascii_hexdigit)
})
}
const COLOUR_FUNCTIONS: &[&str] = &[
"rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color",
];
fn has_colour_function(value: &str) -> bool {
words(value).any(|(word, next)| {
next == Some('(')
&& COLOUR_FUNCTIONS
.iter()
.any(|f| word.eq_ignore_ascii_case(f))
})
}
fn has_named(value: &str) -> bool {
words(value).any(|(word, next)| {
next != Some('(')
&& NAMED_COLOURS
.binary_search(&word.to_ascii_lowercase().as_str())
.is_ok()
})
}
fn words(value: &str) -> impl Iterator<Item = (&str, Option<char>)> {
let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
let mut rest = value;
std::iter::from_fn(move || {
let start = rest.find(is_ident)?;
let tail = &rest[start..];
let len = tail.find(|c: char| !is_ident(c)).unwrap_or(tail.len());
let word = &tail[..len];
let next = tail[len..].chars().next();
rest = &tail[len..];
Some((word, next))
})
.filter(|(word, _)| !word.starts_with("--") && !word.starts_with(|c: char| c.is_ascii_digit()))
}
const NAMED_COLOURS: &[&str] = &[
"aliceblue",
"antiquewhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedalmond",
"blue",
"blueviolet",
"brown",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cornsilk",
"crimson",
"cyan",
"darkblue",
"darkcyan",
"darkgoldenrod",
"darkgray",
"darkgreen",
"darkgrey",
"darkkhaki",
"darkmagenta",
"darkolivegreen",
"darkorange",
"darkorchid",
"darkred",
"darksalmon",
"darkseagreen",
"darkslateblue",
"darkslategray",
"darkslategrey",
"darkturquoise",
"darkviolet",
"deeppink",
"deepskyblue",
"dimgray",
"dimgrey",
"dodgerblue",
"firebrick",
"floralwhite",
"forestgreen",
"fuchsia",
"gainsboro",
"ghostwhite",
"gold",
"goldenrod",
"gray",
"green",
"greenyellow",
"grey",
"honeydew",
"hotpink",
"indianred",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderblush",
"lawngreen",
"lemonchiffon",
"lightblue",
"lightcoral",
"lightcyan",
"lightgoldenrodyellow",
"lightgray",
"lightgreen",
"lightgrey",
"lightpink",
"lightsalmon",
"lightseagreen",
"lightskyblue",
"lightslategray",
"lightslategrey",
"lightsteelblue",
"lightyellow",
"lime",
"limegreen",
"linen",
"magenta",
"maroon",
"mediumaquamarine",
"mediumblue",
"mediumorchid",
"mediumpurple",
"mediumseagreen",
"mediumslateblue",
"mediumspringgreen",
"mediumturquoise",
"mediumvioletred",
"midnightblue",
"mintcream",
"mistyrose",
"moccasin",
"navajowhite",
"navy",
"oldlace",
"olive",
"olivedrab",
"orange",
"orangered",
"orchid",
"palegoldenrod",
"palegreen",
"paleturquoise",
"palevioletred",
"papayawhip",
"peachpuff",
"peru",
"pink",
"plum",
"powderblue",
"purple",
"rebeccapurple",
"red",
"rosybrown",
"royalblue",
"saddlebrown",
"salmon",
"sandybrown",
"seagreen",
"seashell",
"sienna",
"silver",
"skyblue",
"slateblue",
"slategray",
"slategrey",
"snow",
"springgreen",
"steelblue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whitesmoke",
"yellow",
"yellowgreen",
];
#[cfg(test)]
mod tests {
use super::*;
fn lines(css: &str) -> Vec<usize> {
colour_literals(css).iter().map(|f| f.line).collect()
}
#[test]
fn the_named_colours_are_sorted_for_the_search() {
assert!(NAMED_COLOURS.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn each_way_of_writing_a_colour_is_found() {
let css = ".a { color: #fff; }\n\
.b { background: rgba(0, 0, 0, 0.5); }\n\
.c { border-left: 1px solid white; }\n\
:root { --x: oklch(0.5 0.1 200); }\n\
.d { box-shadow: 0 0 0 1px color-mix(in oklch, var(--danger) 90%, black); }\n";
assert_eq!(lines(css), vec![1, 2, 3, 4, 5]);
}
#[test]
fn colour_built_from_tokens_passes() {
let css = ".a { color: var(--danger-text); }\n\
.b { background: color-mix(in oklch, var(--content) 85%, var(--surface-page)); }\n\
.c { border-color: transparent; outline-color: currentColor; }\n\
.d { background: Canvas; }\n\
.e { background: oklch(from var(--action) l c h); }\n";
assert_eq!(lines(css), vec![5]);
}
#[test]
fn selectors_comments_strings_and_urls_are_not_read() {
let css = "/* #fff and white are fine in prose */\n\
#add, #bad > .face { white-space: nowrap; }\n\
.a::before { content: \"#fff\"; }\n\
.b { background-image: url(data:image/svg+xml,%23fff); mask: url(#fade); }\n\
.c { font-family: \"White Rabbit\", Georgia; }\n\
.d { transition: color var(--timing-fast); }\n";
assert!(
colour_literals(css).is_empty(),
"{:?}",
colour_literals(css)
);
}
#[test]
fn a_comment_before_a_declaration_is_not_quoted() {
let found = colour_literals(":root {\n /* the page */\n --surface-page: #ddd6c9;\n}");
assert_eq!(found.len(), 1);
assert_eq!(found[0].line, 3);
assert_eq!(found[0].declaration, "--surface-page: #ddd6c9");
}
#[test]
fn a_colour_word_inside_a_token_name_is_not_a_colour() {
assert_eq!(
colour_literals(".a { color: var(--content-on-black); }"),
Vec::new()
);
}
#[test]
fn the_finding_quotes_its_declaration_and_line() {
let found = colour_literals("\n\n.a {\n color: #c0392b;\n}\n");
assert_eq!(
found,
vec![ColourLiteral {
line: 4,
declaration: "color: #c0392b".to_owned()
}]
);
}
}