makeover-webview 0.29.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.
Documentation
//! Every class this crate is responsible for, as a set rather than one name at
//! a time.
//!
//! The naming functions ([`crate::class`], [`crate::option_class`],
//! [`crate::list::part_class`], [`crate::list::cell_part_class`]) answer "what
//! is this one thing called". That is half the agreement, and 0.27.0 shipped
//! it. The other half is the set: a checker cannot ask "is this app rule
//! re-specifying something makeover already defines" without the list, and this
//! crate is the only place that knows it, because this crate writes the sheet.
//!
//! # Two sets, because there are two questions
//!
//! [`vocabulary`] is the classes the generated stylesheet writes a rule for.
//! That is the set a drift check wants: an app rule for one of these is a
//! restatement of a rule the app already gets, and unlayered app CSS beats
//! `@layer makeover`, so the restatement silently wins.
//!
//! [`names`] is every class this crate can put in markup, which is the first
//! set plus the ones it deliberately leaves unruled. `row-actions`,
//! `cell-actions`, `cell-tokens` and `cell-link` have no rule on purpose: only
//! `.cell-value` takes a colour, because a token carries its own tone and an
//! action is a control rather than text. A class that sets no properties is a
//! class that means "I thought about this", and this crate does not emit those.
//! So a screen renderer legitimately emits names that [`vocabulary`] does not
//! contain, and a test asking "is every class this renderer emits one makeover
//! knows about" has to read [`names`] or it fails on four correct ones.
//!
//! # Why the first set is scraped and not listed
//!
//! A hand-maintained copy of the sheet's contents is the defect being fixed,
//! one level up: it can disagree with the sheet, and the day it does, the
//! checker reads the list and the browser reads the sheet. So [`vocabulary`]
//! parses the CSS this crate generates. There is no second source to drift
//! from, and a class added to an emitter enters the vocabulary in the same
//! commit that adds it.

use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES};
use crate::{Emit, option_class};
use makeover_layout::Selector;
use std::collections::BTreeSet;

/// Every class name the generated stylesheet defines a rule for, prefixed the
/// way `opts` prefixes them.
///
/// Includes the state classes a caller never spells alone (`chosen`,
/// `latched`). Those are deliberately unprefixed: they qualify a prefixed
/// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix
/// moves the thing and not its state.
#[must_use]
pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
    classes_in_css(&crate::stylesheet(opts))
}

/// Every class this crate can put in markup or in a rule.
///
/// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is
/// the set to check a renderer's emitted markup against: a class outside it is
/// a name that renderer invented, which is how quasi-webview came to spell
/// `tabs`, `segmented` and `option` and render every described selector flat.
#[must_use]
pub fn names(opts: &Emit) -> BTreeSet<String> {
    let mut all = vocabulary(opts);
    all.extend(
        ROW_PART_CLASSES
            .iter()
            .chain(CELL_PART_CLASSES)
            .map(|name| crate::class(name, opts)),
    );
    all.extend(
        [Selector::Tabs, Selector::Segmented, Selector::Toggle]
            .into_iter()
            .map(|s| crate::class(option_class(s), opts)),
    );
    all
}

/// The class names a stylesheet's selectors match.
///
/// Public because the check this exists for reads an app's stylesheet too, and
/// comparing what makeover defines against what the app defines is only
/// meaningful if both sides were read the same way.
///
/// Selector text only. A declaration value can hold a dot (`0.5rem`,
/// `transition: .2s`) and none of those are classes, so the scan tracks whether
/// it is inside a declaration block and ignores what it finds there. An at-rule
/// block (`@layer`, `@media`, `@supports`) contains rules rather than
/// declarations, which is why a depth counter alone is not enough: the stack
/// records what kind of block each brace opened.
#[must_use]
pub fn classes_in_css(css: &str) -> BTreeSet<String> {
    let mut found = BTreeSet::new();
    // One entry per open brace: true when that block holds declarations rather
    // than nested rules.
    let mut blocks: Vec<bool> = Vec::new();
    // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
    // prelude, and a prelude starting with `@` opens an at-rule.
    let mut prelude = String::new();

    let mut chars = css.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '/' if chars.peek() == Some(&'*') => {
                // Skip a comment whole. The banner at the top of the sheet is
                // prose about the cascade layer and would otherwise contribute
                // words that look like selectors.
                chars.next();
                let mut star = false;
                for c in chars.by_ref() {
                    if star && c == '/' {
                        break;
                    }
                    star = c == '*';
                }
                prelude.clear();
            }
            '"' | '\'' => {
                // A string is opaque. `content: "\2191"` is the sort caret, not
                // a selector, and a brace inside one would desync the stack.
                let quote = c;
                let mut escaped = false;
                for c in chars.by_ref() {
                    if escaped {
                        escaped = false;
                    } else if c == '\\' {
                        escaped = true;
                    } else if c == quote {
                        break;
                    }
                }
            }
            '{' => {
                let declarations = !prelude.trim_start().starts_with('@');
                if declarations {
                    found.extend(classes_in_selector(&prelude));
                }
                blocks.push(declarations);
                prelude.clear();
            }
            '}' => {
                blocks.pop();
                prelude.clear();
            }
            ';' => prelude.clear(),
            // Only accumulate where a selector can live. Inside a declaration
            // block the text is properties and values.
            _ if !blocks.last().copied().unwrap_or(false) => prelude.push(c),
            _ => {}
        }
    }
    found
}

/// The class names one selector matches on.
fn classes_in_selector(selector: &str) -> Vec<String> {
    let chars: Vec<char> = selector.chars().collect();
    let mut names = Vec::new();
    let mut i = 0;
    while i < chars.len() {
        // A leading digit is a length (`.5rem`), never a class: CSS forbids an
        // identifier starting with one.
        if chars[i] == '.'
            && chars
                .get(i + 1)
                .is_some_and(|c| c.is_alphabetic() || *c == '_')
        {
            let start = i + 1;
            let mut end = start;
            while end < chars.len()
                && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
            {
                end += 1;
            }
            names.push(chars[start..end].iter().collect());
            i = end;
        } else {
            i += 1;
        }
    }
    names
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::list::{cell_part_class, part_class};
    use makeover_layout::{CellPart, RowPart};

    #[test]
    fn the_scrape_finds_the_components_the_sheet_is_built_from() {
        let v = vocabulary(&Emit::default());
        assert!(
            v.len() > 20,
            "scraped {} classes, which reads as a parser failure rather than a small sheet",
            v.len()
        );
        for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
            assert!(
                v.contains(name),
                "the sheet defines .{name} and the scan missed it"
            );
        }
    }

    #[test]
    fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
        // The two halves of the agreement, checked against each other. A naming
        // function returning a class outside `names` would put a class in the
        // markup that nothing downstream can recognise, which is the failure
        // quasi-webview shipped and phase 1 exists to make impossible.
        let opts = Emit::default();
        let all = names(&opts);

        for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
            let name = option_class(selector);
            assert!(
                all.contains(name),
                "option_class({selector:?}) is .{name}, which nothing admits to"
            );
        }
        for part in [
            RowPart::Primary,
            RowPart::Secondary,
            RowPart::Meta,
            RowPart::Actions,
            RowPart::Tokens,
            RowPart::Proportion,
        ] {
            let name = part_class(part);
            assert!(
                all.contains(name),
                "part_class({part:?}) is .{name}, which nothing admits to"
            );
        }
        for part in [
            CellPart::Value,
            CellPart::Tokens,
            CellPart::Actions,
            CellPart::Link,
        ] {
            let name = cell_part_class(part);
            assert!(
                all.contains(name),
                "cell_part_class({part:?}) is .{name}, which nothing admits to"
            );
        }
    }

    #[test]
    fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
        // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
        // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
        // stops them drifting from the matches they sit next to.
        for part in [
            RowPart::Primary,
            RowPart::Secondary,
            RowPart::Meta,
            RowPart::Actions,
            RowPart::Tokens,
            RowPart::Proportion,
        ] {
            assert!(
                ROW_PART_CLASSES.contains(&part_class(part)),
                "{part:?} is missing from ROW_PART_CLASSES"
            );
        }
        for part in [
            CellPart::Value,
            CellPart::Tokens,
            CellPart::Actions,
            CellPart::Link,
        ] {
            assert!(
                CELL_PART_CLASSES.contains(&cell_part_class(part)),
                "{part:?} is missing from CELL_PART_CLASSES"
            );
        }
        // The fallbacks, which are what an upstream addition lands on.
        assert!(ROW_PART_CLASSES.contains(&"row-part"));
        assert!(CELL_PART_CLASSES.contains(&"cell-part"));
    }

    #[test]
    fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
        let plain = vocabulary(&Emit::default());
        let prefixed = vocabulary(&Emit {
            class_prefix: "mo-",
            ..Emit::default()
        });
        assert_eq!(
            plain.len(),
            prefixed.len(),
            "a prefix changed how many classes exist"
        );
        // `chosen` and `latched` never stand alone: the sheet writes
        // `.mo-tab.chosen`, so the state stays bare while the thing moves.
        let states = ["chosen", "latched"];
        for name in &plain {
            let expected = if states.contains(&name.as_str()) {
                name.clone()
            } else {
                format!("mo-{name}")
            };
            assert!(
                prefixed.contains(&expected),
                ".{name} did not move to .{expected} under the prefix"
            );
        }
    }

    #[test]
    fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
        let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
        assert_eq!(found, ["real".to_string()].into_iter().collect());
    }

    #[test]
    fn an_at_rule_does_not_hide_the_selectors_inside_it() {
        let found = classes_in_css(
            "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
        );
        assert_eq!(found, ["wide".to_string()].into_iter().collect());
    }

    #[test]
    fn a_string_is_opaque_and_a_comment_contributes_nothing() {
        let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
        assert_eq!(found, ["caret".to_string()].into_iter().collect());
    }

    #[test]
    fn a_compound_selector_yields_every_class_it_names() {
        let found = classes_in_css(
            ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
        );
        let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
            .into_iter()
            .map(String::from)
            .collect();
        assert_eq!(found, expected);
    }
}