makeover-webview 0.115.0

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser. With the build feature, writes a consumer's generated frontend files and checks the hand-written ones against them.
Documentation
//! What a comment, a string, an escape, a bracket group and an identifier are,
//! for every reader in this crate that scans CSS or script by hand.
//!
//! The vocabulary reader and the drift checks read the same sheets, and a
//! construct one of them lexes differently from the other is a sheet the two
//! disagree about.

/// Which language a source is lexed as.
#[derive(Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "build"), allow(dead_code))]
pub(crate) enum Lang {
    /// A stylesheet: `/* */` comments, `"` and `'` strings, and an unquoted
    /// `url(...)` as a raw token.
    Css,
    /// A script: `//` and `/* */` comments, three quote characters, and regex
    /// literals, inside which a `/*` is text.
    Js,
}

/// What one character of a source is.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Role {
    /// Code, including the quotes around a string and both characters of an
    /// escape outside one.
    Code,
    /// Part of a comment, its delimiters included.
    Comment,
    /// The contents of a string literal, between its quotes.
    Text,
}

/// Every character of `src` with its [`Role`], in order.
///
/// A CSS string ends at an unescaped newline, the way a bad-string does,
/// rather than swallowing the rest of the file. A JS regex literal cannot
/// cross a line either.
pub(crate) fn lex(src: &str, lang: Lang) -> Vec<(char, Role)> {
    #[derive(Clone, Copy)]
    enum State {
        Code,
        Block,
        Line,
        Quoted(char),
        Regex,
        RawUrl,
    }
    let chars: Vec<char> = src.chars().collect();
    let mut out = Vec::with_capacity(chars.len());
    let mut state = State::Code;
    // The last significant character outside any comment or string, which
    // decides whether a `/` opens a regex literal.
    let mut prev: Option<char> = None;
    let mut in_class = false;
    let mut i = 0;
    while i < chars.len() {
        let c = chars[i];
        // A backslash escapes the next character wherever one can appear, so
        // `.a\'b` opens no string and `"\""` closes only once.
        if c == '\\' && !matches!(state, State::Block | State::Line) {
            let role = if matches!(state, State::Quoted(_)) {
                Role::Text
            } else {
                Role::Code
            };
            out.extend(chars[i..chars.len().min(i + 2)].iter().map(|&c| (c, role)));
            i += 2;
            continue;
        }
        match state {
            State::Code => {
                // An unquoted `url(` takes a raw token, so a `/*` inside it is
                // URL text rather than a comment opener.
                let at_url = lang == Lang::Css
                    && chars[i..].len() >= 4
                    && chars[i..i + 3]
                        .iter()
                        .collect::<String>()
                        .eq_ignore_ascii_case("url")
                    && chars[i + 3] == '('
                    && !(i > 0 && is_ident(chars[i - 1]));
                if at_url {
                    out.extend(chars[i..i + 4].iter().map(|&c| (c, Role::Code)));
                    i += 4;
                    while i < chars.len() && chars[i].is_ascii_whitespace() {
                        out.push((chars[i], Role::Code));
                        i += 1;
                    }
                    match chars.get(i) {
                        Some(&q) if q == '"' || q == '\'' => {
                            out.push((q, Role::Code));
                            i += 1;
                            state = State::Quoted(q);
                        }
                        _ => state = State::RawUrl,
                    }
                    continue;
                }
                match c {
                    '/' if chars.get(i + 1) == Some(&'*') => {
                        out.extend([('/', Role::Comment), ('*', Role::Comment)]);
                        i += 2;
                        state = State::Block;
                        continue;
                    }
                    '/' if lang == Lang::Js && chars.get(i + 1) == Some(&'/') => {
                        out.extend([('/', Role::Comment), ('/', Role::Comment)]);
                        i += 2;
                        state = State::Line;
                        continue;
                    }
                    '/' if lang == Lang::Js && regex_can_follow(prev) => {
                        in_class = false;
                        state = State::Regex;
                    }
                    '"' | '\'' | '`' if lang == Lang::Js || c != '`' => state = State::Quoted(c),
                    _ if !c.is_whitespace() => prev = Some(c),
                    _ => {}
                }
                out.push((c, Role::Code));
            }
            State::Regex => {
                if c == '[' && !in_class {
                    in_class = true;
                } else if c == '/' && !in_class {
                    prev = Some('/');
                    state = State::Code;
                } else if c == '\n' {
                    state = State::Code;
                } else if c == ']' {
                    in_class = false;
                }
                out.push((c, Role::Code));
            }
            State::RawUrl => {
                if c == ')' {
                    prev = Some(')');
                    state = State::Code;
                } else if c == '\n' {
                    state = State::Code;
                }
                out.push((c, Role::Code));
            }
            State::Block => {
                if c == '*' && chars.get(i + 1) == Some(&'/') {
                    out.extend([('*', Role::Comment), ('/', Role::Comment)]);
                    i += 2;
                    state = State::Code;
                    continue;
                }
                out.push((c, Role::Comment));
            }
            State::Line => {
                if c == '\n' {
                    state = State::Code;
                }
                out.push((c, Role::Comment));
            }
            State::Quoted(quote) => {
                if c == quote {
                    out.push((c, Role::Code));
                    prev = Some(c);
                    state = State::Code;
                } else {
                    if lang == Lang::Css && c == '\n' {
                        state = State::Code;
                    }
                    out.push((c, Role::Text));
                }
            }
        }
        i += 1;
    }
    out
}

/// Whether a `/` at this point opens a regex literal rather than dividing:
/// a regex may start only where a value cannot already end, so not after an
/// identifier, a number, a closing bracket or a closing quote.
fn regex_can_follow(prev: Option<char>) -> bool {
    prev.is_none_or(|c| {
        !(c.is_alphanumeric() || matches!(c, '_' | '$' | ')' | ']' | '}' | '"' | '\'' | '`'))
    })
}

/// The index of the `close` that balances the `open` at `open_idx`, counting
/// only that pair of brackets. `None` when the group never closes.
pub(crate) fn matching_close<T: PartialEq + Copy>(
    src: &[T],
    open_idx: usize,
    open: T,
    close: T,
) -> Option<usize> {
    let mut depth = 0usize;
    for (k, &x) in src.iter().enumerate().skip(open_idx) {
        if x == open {
            depth += 1;
        } else if x == close {
            depth = depth.checked_sub(1)?;
            if depth == 0 {
                return Some(k);
            }
        }
    }
    None
}

/// Whether `c` may continue a CSS identifier.
pub(crate) fn is_ident(c: char) -> bool {
    c.is_alphanumeric() || c == '-' || c == '_'
}

/// `(byte offset, char)` for every character of a selector outside quotes and
/// outside any `(...)` or `[...]` group, brackets and quotes themselves
/// excluded.
pub(crate) fn top_level(selector: &str) -> impl Iterator<Item = (usize, char)> + '_ {
    let mut depth = 0usize;
    let mut quote: Option<char> = None;
    selector.char_indices().filter(move |&(_, c)| {
        match (quote, c) {
            (Some(q), c) if c == q => quote = None,
            (Some(_), _) => {}
            (None, '"' | '\'') => quote = Some(c),
            (None, '(' | '[') => depth += 1,
            (None, ')' | ']') => depth = depth.saturating_sub(1),
            (None, _) => return depth == 0,
        }
        false
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_group_closes_at_its_own_bracket() {
        let s: Vec<char> = "[a=\")\"](b(c))".chars().collect();
        assert_eq!(matching_close(&s, 0, '[', ']'), Some(6));
        assert_eq!(matching_close(&s, 7, '(', ')'), Some(12));
        assert_eq!(matching_close(b"{ { }", 0, b'{', b'}'), None);
    }

    #[test]
    fn an_escape_outside_a_string_opens_none() {
        let roles: Vec<Role> = lex(r".a\'b { x: 'y' }", Lang::Css)
            .into_iter()
            .map(|(_, r)| r)
            .collect();
        assert_eq!(roles.iter().filter(|r| **r == Role::Text).count(), 1);
    }

    #[test]
    fn top_level_skips_groups_and_strings() {
        let kept: String = top_level(".a:is(.b, .c)[t=\"x,y\"], .d")
            .map(|(_, c)| c)
            .collect();
        assert_eq!(kept, ".a:is, .d");
    }
}