makeover 5.11.0

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! Finding colour written into a stylesheet rather than taken from a token.
//!
//! A colour an app writes itself is a colour no floor reaches: makeover lifts
//! its own inks until they read on every ground, and a literal is whatever it
//! was typed as, under every theme. A build script runs this over the sheets
//! people edit and refuses what it finds.

/// A colour written as a literal: the line it is on, counted from 1, and the
/// declaration that holds it.
#[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)
    }
}

/// Every declaration in `css` whose value writes a colour: a hex colour, a
/// colour function (`rgb()`, `oklch()` and the rest), or a named colour in a
/// property that takes one.
///
/// A mix or a relative colour built only from tokens has no literal in it and
/// passes, and so do `transparent`, `currentColor` and the system colours,
/// which name no colour of their own. Selectors, comments, strings and `url()`
/// are not read, so `#add` as an id or a hex digit in a data URI is not a
/// finding.
pub fn colour_literals(css: &str) -> Vec<ColourLiteral> {
    crate::look::sheet_declarations(css)
        .into_iter()
        .filter(|d| writes_colour(&d.property, &d.value))
        .map(|d| ColourLiteral {
            line: d.line,
            declaration: d.text,
        })
        .collect()
}

/// Whether a declaration of `property` (lowercase) with `value` writes a colour.
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)
}

/// Properties whose value a named colour can be, by stem: `border` covers
/// `border-left-color` and the shorthand alike.
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)
    })
}

/// The functions that write a colour from channels.
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()
    })
}

/// Each identifier in `value` with the character after it. A custom property's
/// name is one identifier, `--content-on-black`, so a colour word inside it is
/// not read as a colour.
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()))
}

/// The CSS named colours, sorted. `transparent`, `currentcolor` and the system
/// colours are not here: they name no colour of their own.
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";
        // A relative colour is a function call, and its channels are the
        // token's, but the function is where an author can write a literal.
        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()
            }]
        );
    }
}