Skip to main content

makeover_webview/build/
drift.rs

1//! Checks that a hand-written frontend still agrees with the crate that
2//! generates its siblings.
3//!
4//! The generated files cannot drift: they ask makeover-geometry for the answer.
5//! The hand-written ones state it, and a stylesheet or a script that disagrees
6//! with the crate raises no error at any point. It is a rule that quietly
7//! stops matching where it used to. Cheaper to read a panic naming the line.
8//!
9//! Deliberately assertions and not substitutions. A JS or CSS file that has to
10//! be generated to be correct stops being readable on its own, and it is worth
11//! something that you can still open the frontend in a browser and have it
12//! work.
13
14use std::path::{Path, PathBuf};
15
16use super::rerun;
17use crate::Emit;
18use crate::css_lex::{Lang, Role, is_ident, lex, matching_close};
19use makeover_geometry::{Density, SizeClass};
20
21/// The declaration this check reads. Shared vocabulary, not a parameter: two
22/// apps and a server naming the same string want the same name for it.
23const CONST_NAME: &str = "TOUCH_DENSITY";
24
25/// The capability sniffs the media query replaced, so neither can come back by
26/// copy-paste.
27///
28/// Both ask the hardware what it has rather than what is pointing at the
29/// screen, so both say yes to a touchscreen laptop driving a mouse.
30const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
31
32/// Fail the build if a JS copy of the touch-density query has drifted from
33/// [`Density::Touch`].
34///
35/// Every `.js` file under `js_dir`, recursively, must state the crate's own
36/// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must
37/// declare it, and no file may name a capability sniff.
38///
39/// The string is the crate's and no app gets a say in it, which is why this
40/// check takes no policy argument. The generated `geometry.css` already keys
41/// its touch gap overrides on the same condition, so the gestures and the
42/// spacing agree by construction rather than by two people remembering.
43///
44/// Emits `cargo::rerun-if-changed` for every file it read.
45///
46/// # Panics
47///
48/// If `js_dir` cannot be read, if no declaration is found, or if any file
49/// disagrees with the crate. A build script has nowhere useful to return an
50/// error to, and a frontend that disagrees with its own stylesheet is worse
51/// than a failed build.
52pub fn check_touch_density(js_dir: impl AsRef<Path>) {
53    let js_dir = js_dir.as_ref();
54    let want = Density::Touch.media_condition();
55    let mut wrong: Vec<String> = Vec::new();
56    let mut found = 0usize;
57
58    let files = js_files(js_dir);
59    for path in &files {
60        let raw = read(path);
61        // Read the code with comments blanked: a commented-out declaration is
62        // not a declaration, and a comment naming a sniff is not a use. String
63        // contents stay, because the literal being read is itself a string.
64        let src = blank(&raw, Lang::Js);
65        let name = shown(path, Some(js_dir));
66
67        for (offset, literal) in touch_density_literals(&src) {
68            found += 1;
69            if literal != want {
70                wrong.push(format!(
71                    "  {name}:{}  {CONST_NAME} = '{literal}'",
72                    line_of(&src, offset)
73                ));
74            }
75        }
76
77        for needle in SNIFFS {
78            if let Some(offset) = src.find(needle) {
79                wrong.push(format!(
80                    "  {name}:{}  {needle} -- device sniff, not a density question",
81                    line_of(&src, offset)
82                ));
83            }
84        }
85    }
86
87    assert!(
88        found > 0,
89        "no {CONST_NAME} literal found under {}.\n\n\
90         A frontend that asks whether it is being touched states\n\
91         makeover_geometry::Density::Touch's media condition in a const of that\n\
92         name, and this check exists to keep every copy equal to it. If the\n\
93         const was renamed, rename it back rather than dropping the check; if\n\
94         this frontend genuinely asks no density question, drop the call.",
95        js_dir.display()
96    );
97
98    assert!(
99        wrong.is_empty(),
100        "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
101         Density::Touch.media_condition() is: {want}\n\n\
102         Wrong:\n{}\n\n\
103         Fix the JS to state the crate's string. Never widen it to catch a\n\
104         device the query misses: density is what is pointing at the screen,\n\
105         and a laptop with a touchscreen and a mouse is a pointer device.",
106        wrong.join("\n")
107    );
108
109    for path in &files {
110        rerun(path);
111    }
112    // The directory too, so a file added under it reruns the check: a
113    // rerun keyed only on the files that existed last time never sees the
114    // one somebody just copied in.
115    rerun(js_dir);
116}
117
118/// A file's text, for a check that cannot run without it.
119fn read(path: &Path) -> String {
120    std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
121}
122
123/// A path as a reader of the error sees it: relative to `root` when there is
124/// one.
125fn shown(path: &Path, root: Option<&Path>) -> String {
126    root.and_then(|root| path.strip_prefix(root).ok())
127        .unwrap_or(path)
128        .display()
129        .to_string()
130}
131
132/// The sheets under `frontend/css`, recursively, with the directory named for
133/// a rerun so a sheet added later reruns the check.
134fn frontend_css(frontend: &Path) -> (PathBuf, Vec<PathBuf>) {
135    let css = frontend.join("css");
136    rerun(&css);
137    let files = files_with_extension(&css, "css");
138    (css, files)
139}
140
141/// Every `.js` file under `dir`, recursively, sorted.
142fn js_files(dir: &Path) -> Vec<PathBuf> {
143    files_with_extension(dir, "js")
144}
145
146/// Every file under `dir` with extension `ext`, recursively, sorted.
147///
148/// Recursive because a consumer's frontend is not always one flat directory:
149/// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
150/// `static/`, and a check that silently skipped the nested half would report
151/// clean on the files most likely to have been copied.
152fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
153    let mut out = Vec::new();
154    let mut stack = vec![dir.to_path_buf()];
155    while let Some(d) = stack.pop() {
156        for entry in std::fs::read_dir(&d)
157            .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
158            .flatten()
159        {
160            let path = entry.path();
161            if path.is_dir() {
162                stack.push(path);
163            } else if path.extension().is_some_and(|x| x == ext) {
164                out.push(path);
165            }
166        }
167    }
168    out.sort();
169    out
170}
171
172/// `(byte offset of the declaration, the literal's contents)` for every
173/// `const TOUCH_DENSITY = '...'` in a JS source already passed through
174/// [`blank`] with [`Lang::Js`], so a commented-out copy is not a declaration.
175///
176/// The name must stand alone, whitespace may surround the `=`, and the literal
177/// may open with any of JS's three quote characters.
178fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
179    let mut out = Vec::new();
180    let mut at = 0;
181    while let Some(i) = src[at..].find(CONST_NAME) {
182        let start = at + i;
183        at = start + CONST_NAME.len();
184        // A name glued to another identifier (`OLD_TOUCH_DENSITY`,
185        // `TOUCH_DENSITY2`) is not this const.
186        if start > 0 && is_ident_byte(src.as_bytes()[start - 1]) {
187            continue;
188        }
189        if src.as_bytes().get(at).copied().is_some_and(is_ident_byte) {
190            continue;
191        }
192        // `const NAME = '...'`, any run of whitespace around the `=`.
193        let rest = &src[at..];
194        let lead = rest.len() - rest.trim_start().len();
195        let rest = &rest[lead..];
196        if !rest.starts_with('=') {
197            continue;
198        }
199        // `==`/`===` is a comparison, not a declaration: after the first `=`
200        // the next non-space must open a literal.
201        let rest = &rest[1..];
202        let gap = rest.len() - rest.trim_start().len();
203        let rest = &rest[gap..];
204        let Some(quote @ ('\'' | '"' | '`')) = rest.chars().next() else {
205            continue;
206        };
207        let body = at + lead + 1 + gap + quote.len_utf8();
208        if let Some(j) = src[body..].find(quote) {
209            out.push((start, &src[body..body + j]));
210            at = body + j + 1;
211        }
212    }
213    out
214}
215
216/// Whether `b` may appear in a JS identifier, so a substring hit on
217/// [`CONST_NAME`] can be rejected as part of a longer name.
218fn is_ident_byte(b: u8) -> bool {
219    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
220}
221
222fn line_of(src: &str, offset: usize) -> usize {
223    src[..offset].matches('\n').count() + 1
224}
225
226/// Which files a check reads.
227#[derive(Debug, Clone)]
228enum Source {
229    /// A frontend directory, scanned recursively.
230    Frontend(PathBuf),
231    /// A named list of files.
232    Files(Vec<PathBuf>),
233}
234
235impl Source {
236    fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
237        Self::Files(paths.iter().map(|p| p.as_ref().to_path_buf()).collect())
238    }
239}
240
241/// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
242///
243/// Every pixel width named by a media or container query must be a
244/// [`SizeClass`] boundary or one of the [`tuning`](Self::tuning) widths. A
245/// container query is checked the same way: `@container screen (width < 600px)`
246/// states a width, and a container whose inline size tracks the shell is a
247/// breakpoint in disguise, so it must move with [`SizeClass`] or be declared a
248/// tuning width like any other.
249///
250/// ```no_run
251/// makeover_webview::build::drift::Breakpoints::in_frontend("frontend")
252///     .tuning(&[1400])
253///     .run();
254/// ```
255///
256/// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
257/// stylesheets and leaves every hand-written query behind, and the result is a
258/// stylesheet that disagrees with itself at the old boundary rather than an
259/// error.
260///
261/// The generated stylesheets are scanned too, and pass by construction: they
262/// ask makeover-geometry for the number rather than stating it. Scanning them
263/// means a consumer never has to name which files are hand-written.
264#[derive(Debug, Clone)]
265#[must_use = "a check does nothing until it is run"]
266pub struct Breakpoints {
267    source: Source,
268    tuning: Vec<u16>,
269}
270
271impl Breakpoints {
272    /// Every `.css` under `frontend/css` and every `.js` under `frontend/js`,
273    /// recursively.
274    pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
275        Self {
276            source: Source::Frontend(frontend.as_ref().to_path_buf()),
277            tuning: Vec::new(),
278        }
279    }
280
281    /// A named list of files rather than a tree.
282    ///
283    /// For a frontend whose generated and hand-written files share a directory,
284    /// with nothing to point a directory scan at: the MNW server keeps both
285    /// under `static/` beside a bundler's output. The list is hand-maintained,
286    /// and a stylesheet nobody adds to it is unchecked rather than failing, so
287    /// prefer [`in_frontend`](Self::in_frontend) where the layout allows it.
288    ///
289    /// A `.js` path is read as script and anything else as stylesheet.
290    pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
291        Self {
292            source: Source::files(paths),
293            tuning: Vec::new(),
294        }
295    }
296
297    /// Widths inside a shell where something reflows without the shell
298    /// changing: a dashboard dropping from three columns to two, a pane's width
299    /// cap ending.
300    ///
301    /// The one thing an app gets a say in. A shell boundary is a [`SizeClass`]
302    /// edge and belongs to makeover-geometry; nothing switches shells at a
303    /// tuning width, so it does not move when a size class does. Each entry
304    /// owes a note beside it saying what it tunes: this list is where a genuine
305    /// boundary goes to hide from the check.
306    pub fn tuning(mut self, widths: &[u16]) -> Self {
307        self.tuning.extend_from_slice(widths);
308        self
309    }
310
311    /// Run the check. Emits `cargo::rerun-if-changed` for every file it read,
312    /// and for the scanned directories.
313    ///
314    /// # Panics
315    ///
316    /// If a listed file cannot be read (a listed path that no longer exists is
317    /// a check covering less than it says), or if any width is neither a
318    /// size-class boundary nor a tuning width. A build script has nowhere
319    /// useful to return an error to.
320    pub fn run(&self) {
321        match &self.source {
322            Source::Frontend(frontend) => {
323                let (_, mut files) = frontend_css(frontend);
324                // A CSS-only frontend ships no `js/`; the scan is best-effort
325                // and a missing directory is skipped rather than failing.
326                let js = frontend.join("js");
327                rerun(&js);
328                if js.is_dir() {
329                    files.extend(js_files(&js));
330                }
331                check_paths(&files, &self.tuning, Some(frontend));
332            }
333            Source::Files(paths) => check_paths(paths, &self.tuning, None),
334        }
335    }
336}
337
338/// The check itself. `root`, when given, is stripped from reported paths.
339fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
340    let allowed = allowed_widths(tuning_widths);
341    let edges = size_class_widths();
342    let mut stale: Vec<String> = Vec::new();
343
344    for path in paths {
345        let raw = read(path);
346        let name = shown(path, root);
347
348        if path.extension().is_some_and(|x| x == "js") {
349            // No declarations in JS, so any parenthesised width is a query.
350            for (offset, bound) in js_widths(&blank(&raw, Lang::Js)) {
351                if !bound.is_allowed(&allowed, &edges) {
352                    stale.push(format!(
353                        "  {name}:{}  ({})",
354                        line_of(&raw, offset),
355                        bound.width
356                    ));
357                }
358            }
359            continue;
360        }
361
362        // Comments first: a note about a breakpoint that used to be here is
363        // prose, not a rule, and should not fail a build.
364        let src = blank(&raw, Lang::Css);
365        for (offset, condition) in media_conditions(&src) {
366            for bound in media_bounds(condition) {
367                if !bound.is_allowed(&allowed, &edges) {
368                    stale.push(format!(
369                        "  {name}:{}  @media{condition}  ({})",
370                        line_of(&src, offset),
371                        bound.width
372                    ));
373                }
374            }
375        }
376    }
377
378    assert!(
379        stale.is_empty(),
380        "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
381         Allowed: {allowed:?}\n\
382         ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
383         Stale:\n{}\n\n\
384         Size classes are written in range syntax, `(width < 600px)`, because\n\
385         `(max-width: 599px)` leaves 599.5px in no class. If a size class moved,\n\
386         update these to match. If one of these is a new\n\
387         tuning width inside the wide shell rather than a shell boundary, add it\n\
388         to the caller's tuning list with a note saying what it tunes.\n\n\
389         Best of all, make the rule dimensional so it needs no threshold: a grid\n\
390         wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
391         clamp(). A threshold is for what appears and disappears.",
392        allowed
393            .iter()
394            .filter(|px| !tuning_widths.contains(px))
395            .collect::<Vec<_>>(),
396        stale.join("\n")
397    );
398
399    for path in paths {
400        rerun(path);
401    }
402}
403
404/// Every width a hand-written media query is allowed to name.
405///
406/// Read out of [`SizeClass::media_condition`] rather than typed, which is the
407/// whole point: that is the one place the numbers come from, and a bump in
408/// makeover-geometry has to reach the stylesheet through here.
409fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
410    let mut widths = size_class_widths();
411    widths.extend_from_slice(tuning_widths);
412    widths.sort_unstable();
413    widths.dedup();
414    widths
415}
416
417/// The widths at which [`SizeClass`] changes, read out of it rather than
418/// typed: where one class ends and the next begins. A hand-written bound on
419/// one of these edges owns the point the classes either side both claim, so
420/// the direction it is written in matters; a tuning width has no such edge.
421fn size_class_widths() -> Vec<u16> {
422    let mut widths: Vec<u16> = SizeClass::all()
423        .iter()
424        .flat_map(|c| media_widths(&c.media_condition()))
425        .map(|width| match width {
426            Width::Px(px) => px,
427            Width::Other(raw) => {
428                panic!("makeover-geometry wrote a size-class width this check cannot read: {raw}")
429            }
430        })
431        .collect();
432    widths.sort_unstable();
433    widths.dedup();
434    widths
435}
436
437/// A width named in a media condition.
438#[derive(Debug, Clone, PartialEq, Eq)]
439enum Width {
440    /// A whole number of CSS pixels: `600px`, or `37.5em` at the 16px a media
441    /// query's `em` is measured against.
442    Px(u16),
443    /// Anything this cannot state in whole pixels: `599.98px`, `50vw`, `40ch`.
444    /// Never a boundary, so always reported.
445    Other(String),
446}
447
448/// A width named in a media condition, with the side of the comparison its
449/// operator puts it on.
450#[derive(Debug, Clone, PartialEq, Eq)]
451struct Bound {
452    width: Width,
453    direction: Direction,
454}
455
456/// Whether a bound is one edge of a partition, as a size class is written, or
457/// straddles the boundary point instead.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459enum Direction {
460    /// `width < N`, `width >= N`, `N <= width` or `N > width`: the classes
461    /// either side still partition at `N`.
462    Edge,
463    /// `max-width: N`, `width <= N`, `N >= width`, `width > N` or `N < width`:
464    /// the bound claims the point at `N` itself, which [`SizeClass`] owns.
465    Straddles,
466}
467
468impl Bound {
469    /// A bound passes when its number is allowed and, when that number is a
470    /// size-class edge, it is written as an edge rather than a bound that
471    /// straddles it. A tuning width has no class on either side to disagree
472    /// with, so its direction is left alone.
473    fn is_allowed(&self, allowed: &[u16], edges: &[u16]) -> bool {
474        match &self.width {
475            Width::Px(px) => {
476                allowed.contains(px)
477                    && !(edges.contains(px) && self.direction == Direction::Straddles)
478            }
479            Width::Other(_) => false,
480        }
481    }
482}
483
484impl std::fmt::Display for Width {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        match self {
487            Self::Px(px) => write!(f, "{px}px"),
488            Self::Other(raw) => f.write_str(raw),
489        }
490    }
491}
492
493/// Every width in a media condition, in the order it appears.
494fn media_widths(condition: &str) -> Vec<Width> {
495    media_bounds(condition)
496        .into_iter()
497        .map(|bound| bound.width)
498        .collect()
499}
500
501/// Every width in a media condition with the direction its operator gives it,
502/// in the order it appears.
503///
504/// Reads both spellings of a width feature: `(max-width: 599px)` and range
505/// syntax, `(width < 600px)` or `(600px <= width < 840px)`, which is what
506/// [`SizeClass::media_condition`] writes. A feature group is any `( ... )`
507/// naming `width` and not `device-width`, and every length inside it counts,
508/// including one nested in a function like `calc(...)`. Units are read rather
509/// than assumed: `48em` is 768px, not 48.
510fn media_bounds(condition: &str) -> Vec<Bound> {
511    width_groups(condition)
512        .into_iter()
513        .flat_map(|(_, group)| lengths(group))
514        .collect()
515}
516
517/// `(byte offset, inner text)` of every parenthesised group that names a width
518/// feature. A nested group (`calc(...)`) stays inside its outer group's text,
519/// so a width buried in a function is read rather than skipped.
520fn width_groups(src: &str) -> Vec<(usize, &str)> {
521    let mut out: Vec<(usize, &str)> = Vec::new();
522    let mut stack: Vec<usize> = Vec::new();
523    for (i, c) in src.char_indices() {
524        match c {
525            '(' => stack.push(i),
526            ')' => {
527                if let Some(start) = stack.pop() {
528                    let inner = &src[start + 1..i];
529                    // A group enclosing one already read, as in
530                    // `matchMedia('(width < 600px)')`, adds nothing: the inner
531                    // group is the feature. This keeps nesting for the sake of
532                    // reading `calc(...)`, without reading a call around it.
533                    let encloses_read = out.iter().any(|&(inner_start, _)| inner_start > start);
534                    if names_width(inner) && !encloses_read {
535                        out.push((start, inner));
536                    }
537                }
538            }
539            _ => {}
540        }
541    }
542    out
543}
544
545/// Whether a feature group is about the viewport's width: `width`,
546/// `min-width` or `max-width` as a whole word, not `device-width` and not a
547/// longer identifier that ends in it.
548fn names_width(inner: &str) -> bool {
549    width_token_at(inner).is_some()
550}
551
552/// Byte offset of the `width` in a feature group: `width`, `min-width` or
553/// `max-width` as a whole word, not `device-width` and not a longer
554/// identifier that ends in it.
555fn width_token_at(inner: &str) -> Option<usize> {
556    inner.match_indices("width").find_map(|(at, _)| {
557        let before = &inner[..at];
558        let prefix_ok = before.is_empty()
559            || before.ends_with("min-")
560            || before.ends_with("max-")
561            || before.chars().next_back().is_some_and(|c| !is_ident(c));
562        let after = inner[at + "width".len()..].chars().next();
563        (prefix_ok && after.is_none_or(|c| !is_ident(c))).then_some(at)
564    })
565}
566
567/// Every `<number><unit>` in a feature group, each with the direction its
568/// operator gives it.
569fn lengths(group: &str) -> Vec<Bound> {
570    let bytes = group.as_bytes();
571    let width_at = width_token_at(group);
572    let mut out = Vec::new();
573    let mut i = 0;
574    while i < bytes.len() {
575        // A number may open with a digit or with a leading dot (`.5em`); it
576        // must not continue an identifier or a preceding number.
577        let prev_ok = i == 0
578            || !(bytes[i - 1].is_ascii_alphanumeric()
579                || bytes[i - 1] == b'-'
580                || bytes[i - 1] == b'.');
581        let starts_number = prev_ok
582            && (bytes[i].is_ascii_digit()
583                || (bytes[i] == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)));
584        if !starts_number {
585            i += 1;
586            continue;
587        }
588        let start = i;
589        while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
590            i += 1;
591        }
592        let number = &group[start..i];
593        let unit_start = i;
594        while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
595            i += 1;
596        }
597        let unit = &group[unit_start..i];
598        if unit.is_empty() {
599            continue;
600        }
601        let raw = &group[start..i];
602        let scale = match unit {
603            "px" => Some(1.0),
604            "em" | "rem" => Some(16.0),
605            _ => None,
606        };
607        let px = scale.and_then(|scale| number.parse::<f64>().ok().map(|n| n * scale));
608        let width = match px {
609            Some(px) if px.fract() == 0.0 && px >= 0.0 && px <= f64::from(u16::MAX) => {
610                // Whole and in range, checked above.
611                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
612                Width::Px(px as u16)
613            }
614            _ => Width::Other(raw.to_string()),
615        };
616        out.push(Bound {
617            width,
618            direction: direction_of(group, start, i, width_at),
619        });
620    }
621    out
622}
623
624/// Which side of `width` a number sits on, and whether its operator makes the
625/// bound an [`Edge`](Direction::Edge) of the partition or one that
626/// [`straddles`](Direction::Straddles) it.
627///
628/// The classes partition the viewport, so the bound between two of them is
629/// exclusive on one side and inclusive on the other: `width < 600px` is an
630/// edge, and so is `600px <= width`. `max-width: 600px` is not, because it
631/// also matches the width where `Medium` begins.
632fn direction_of(group: &str, start: usize, end: usize, width_at: Option<usize>) -> Direction {
633    let Some(width_at) = width_at else {
634        return Direction::Edge;
635    };
636    let width_end = width_at + "width".len();
637    if start >= width_end {
638        // The number is to the right of `width`.
639        let op = &group[width_end..start];
640        if op.contains("<=") {
641            Direction::Straddles
642        } else if op.contains(">=") || op.contains('<') {
643            Direction::Edge
644        } else if op.contains('>') {
645            Direction::Straddles
646        } else if group[..width_at].ends_with("min-") {
647            Direction::Edge
648        } else {
649            // `max-width: N` and a bare `width: N` both claim `N` itself.
650            Direction::Straddles
651        }
652    } else {
653        // The number is to the left of `width`.
654        let op = &group[end..width_at];
655        if op.contains("<=") || op.contains('>') {
656            Direction::Edge
657        } else {
658            Direction::Straddles
659        }
660    }
661}
662
663/// `(byte offset of the at-keyword, the condition text)`.
664///
665/// Reads the at-rules that carry a width condition: `@media` and `@container`
666/// (case-insensitive, terminated by `{`), and `@custom-media` and `@import`,
667/// which end at `;`. A container name ahead of its condition (`@container
668/// screen (width < 600px)`) lands in the condition text, and [`width_groups`]
669/// skips it, reading only the parenthesised width feature.
670fn media_conditions(css: &str) -> Vec<(usize, &str)> {
671    let lower = css.to_ascii_lowercase();
672    let mut out = Vec::new();
673    let mut at = 0;
674    while let Some(rel) = lower[at..].find('@') {
675        let start = at + rel;
676        let kw_start = start + 1;
677        let kw_end = css[kw_start..]
678            .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
679            .map_or(css.len(), |i| kw_start + i);
680        let terminator = match &lower[kw_start..kw_end] {
681            "media" | "container" => '{',
682            "custom-media" | "import" => ';',
683            _ => {
684                at = start + 1;
685                continue;
686            }
687        };
688        match css[kw_end..].find(terminator) {
689            Some(j) => {
690                out.push((start, &css[kw_end..kw_end + j]));
691                at = kw_end + j + 1;
692            }
693            None => break,
694        }
695    }
696    out
697}
698
699/// `(byte offset, bound)` for every width feature in a JS source.
700///
701/// The parentheses are the whole test, and they have to be: a media condition
702/// is always parenthesized and a CSS declaration never is, so `'max-width:
703/// 320px'` in an inline-style string is not a breakpoint and must not read as
704/// one. Range syntax, `(width < 600px)`, is read the same way as in a
705/// stylesheet.
706fn js_widths(src: &str) -> Vec<(usize, Bound)> {
707    width_groups(src)
708        .into_iter()
709        .filter(|(_, group)| is_feature(group))
710        .flat_map(|(offset, group)| lengths(group).into_iter().map(move |b| (offset, b)))
711        .collect()
712}
713
714/// Whether a parenthesised group in script is a media feature rather than a
715/// call's arguments: `(max-width: 600px)` or `(width < 600px)`, not
716/// `Math.max(width, 600)`.
717fn is_feature(group: &str) -> bool {
718    let group = group.trim();
719    let colon = ["min-width", "max-width", "width"].iter().any(|name| {
720        group
721            .strip_prefix(name)
722            .is_some_and(|rest| rest.trim_start().starts_with(':'))
723    });
724    colon || group.contains(['<', '>'])
725}
726
727/// Blank the parts of `src` that are not code, keeping every byte offset.
728///
729/// Comments always go. String contents go in a stylesheet, where neither is a
730/// rule and `content: "/*"` must not open a comment, and stay in script,
731/// because a media query in script is a string (`matchMedia('(width <
732/// 600px)')`). Blanked characters become as many spaces as they had bytes,
733/// except that a newline stays a newline so line numbers still line up.
734fn blank(src: &str, lang: Lang) -> String {
735    let mut out = String::with_capacity(src.len());
736    for (c, role) in lex(src, lang) {
737        if role == Role::Code || (role == Role::Text && lang == Lang::Js) {
738            out.push(c);
739        } else if c == '\n' {
740            out.push('\n');
741        } else {
742            out.extend(std::iter::repeat_n(' ', c.len_utf8()));
743        }
744    }
745    out
746}
747
748/// Blank every `@layer <name> { ... }` block whose name is in `names`, keeping
749/// byte offsets and line breaks. Comments and strings are skipped while the
750/// braces are matched, the same way [`blank`] reads them.
751///
752/// An important declaration inside a dropped layer is still read: `!important`
753/// inverts layer order, so a rule in an earlier layer marked important beats
754/// every later layer, and dropping it whole would hide a real override. The
755/// modern-reset reduced-motion idiom lives exactly there. [`blank_dropped_layer`]
756/// keeps those rules and blanks the rest.
757///
758/// `@layer` and its name are read case-insensitively, and a dotted sublayer
759/// (`@layer reset.base`) is beneath whatever `reset` is beneath.
760fn drop_layers(css: &str, names: &[&str]) -> String {
761    if names.is_empty() {
762        return css.to_owned();
763    }
764    let scan = blank(css, Lang::Css);
765    let scan = scan.as_bytes();
766    let mut out = css.as_bytes().to_vec();
767    let mut at = 0;
768    while let Some(found) = find_at_layer(scan, at) {
769        let mut i = found + b"@layer".len();
770        while i < scan.len() && scan[i].is_ascii_whitespace() {
771            i += 1;
772        }
773        let start = i;
774        while i < scan.len()
775            && (scan[i].is_ascii_alphanumeric() || matches!(scan[i], b'-' | b'_' | b'.'))
776        {
777            i += 1;
778        }
779        let name = &css[start..i];
780        while i < scan.len() && scan[i].is_ascii_whitespace() {
781            i += 1;
782        }
783        if i >= scan.len() || scan[i] != b'{' || !layer_is_beneath(name, names) {
784            at = found + 1;
785            continue;
786        }
787        match matching_close(scan, i, b'{', b'}') {
788            Some(close) => {
789                blank_dropped_layer(&mut out, scan, found, i, close);
790                at = close + 1;
791            }
792            None => {
793                blank_span(&mut out, found, scan.len());
794                at = scan.len();
795            }
796        }
797    }
798    String::from_utf8(out).unwrap_or_else(|_| css.to_owned())
799}
800
801/// Whether a layer named `name` sits beneath every layer in `names`.
802///
803/// A sublayer is beneath its parent, so `reset.base` is beneath `reset`.
804fn layer_is_beneath(name: &str, names: &[&str]) -> bool {
805    names.iter().any(|beneath| {
806        name == *beneath
807            || name
808                .strip_prefix(beneath)
809                .is_some_and(|rest| rest.starts_with('.'))
810    })
811}
812
813/// Case-insensitive `@layer` search, since at-rule keywords are case-insensitive
814/// and `@LAYER` is the same rule as `@layer`.
815fn find_at_layer(haystack: &[u8], from: usize) -> Option<usize> {
816    let needle = b"@layer";
817    let mut i = from;
818    while i + needle.len() <= haystack.len() {
819        if haystack[i..i + needle.len()].eq_ignore_ascii_case(needle) {
820            return Some(i);
821        }
822        i += 1;
823    }
824    None
825}
826
827/// Blank the bytes of `out[start..end]` to spaces, keeping line breaks.
828fn blank_span(out: &mut [u8], start: usize, end: usize) {
829    for byte in &mut out[start..end] {
830        if *byte != b'\n' {
831            *byte = b' ';
832        }
833    }
834}
835
836/// Blank one dropped `@layer` block, keeping any declaration rule that carries
837/// an `!important` declaration.
838///
839/// `head` is the index of the `@layer` keyword, `open` the opening brace and
840/// `close` the matching closing brace. The header and closing brace are always
841/// blanked so a kept rule rises to the surface; the rule bodies decide
842/// themselves.
843fn blank_dropped_layer(out: &mut [u8], scan: &[u8], head: usize, open: usize, close: usize) {
844    blank_span(out, head, open + 1);
845    blank_dropped_content(out, scan, open + 1, close);
846    blank_span(out, close, close + 1);
847}
848
849/// Blank the non-important rules of a dropped layer's content, leaving any
850/// declaration rule whose body carries `!important` where it is.
851///
852/// An at-rule block (`@media`, a nested `@layer`) is descended into, since a
853/// rule it holds can carry the important declaration this keeps.
854fn blank_dropped_content(out: &mut [u8], scan: &[u8], start: usize, end: usize) {
855    let mut i = start;
856    while i < end {
857        let mut open = i;
858        while open < end && scan[open] != b'{' && scan[open] != b';' {
859            open += 1;
860        }
861        if open >= end {
862            blank_span(out, i, end);
863            break;
864        }
865        if scan[open] == b';' {
866            blank_span(out, i, open + 1);
867            i = open + 1;
868            continue;
869        }
870        let Some(close) = matching_close(&scan[..end], open, b'{', b'}') else {
871            blank_span(out, i, end);
872            break;
873        };
874        let is_at_rule = scan[i..open]
875            .iter()
876            .position(|b| !b.is_ascii_whitespace())
877            .is_some_and(|k| scan[i + k] == b'@');
878        if is_at_rule {
879            blank_dropped_content(out, scan, open + 1, close);
880        } else if !has_important(&scan[open + 1..close]) {
881            blank_span(out, i, close + 1);
882        }
883        i = close + 1;
884    }
885}
886
887/// Whether a declaration block ends any declaration with `!important`, allowing
888/// the whitespace the spec permits between the `!` and the keyword.
889fn has_important(bytes: &[u8]) -> bool {
890    let keyword = b"important";
891    let mut i = 0;
892    while i < bytes.len() {
893        if bytes[i] == b'!' {
894            let mut k = i + 1;
895            while k < bytes.len() && bytes[k].is_ascii_whitespace() {
896                k += 1;
897            }
898            if k + keyword.len() <= bytes.len()
899                && bytes[k..k + keyword.len()].eq_ignore_ascii_case(keyword)
900            {
901                return true;
902            }
903        }
904        i += 1;
905    }
906    false
907}
908
909/// The layers a sheet's own order statement puts before `makeover`.
910///
911/// `@layer reset, quasi, makeover;` says that a rule in `reset` or `quasi`
912/// loses to every generated rule whatever its selector, which is the same thing
913/// [`Vocabulary::beneath`] lets an app state from its build script. A sheet
914/// that states it itself (quasi-webview's `SCREEN_CSS` does, and every app that
915/// serves it carries the statement) needs no second copy of the claim.
916fn layers_ordered_below_makeover(css: &str) -> Vec<String> {
917    let scan = blank(css, Lang::Css);
918    let mut below = Vec::new();
919    for statement in scan.split(';') {
920        let Some(at) = statement.rfind("@layer") else {
921            continue;
922        };
923        let names: Vec<&str> = statement[at + "@layer".len()..]
924            .split(',')
925            .map(str::trim)
926            .collect();
927        if names.iter().any(|name| name.contains(['{', '}'])) {
928            continue;
929        }
930        if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
931            below.extend(names[..position].iter().map(|name| (*name).to_string()));
932        }
933    }
934    below
935}
936
937/// A reviewed exception to [`Vocabulary`]: a collision the app has read and
938/// kept, with a note beside it saying why.
939///
940/// It expires when the collision does. An exception that no longer collides
941/// fails the check, since an unused licence is where the next real collision
942/// lands unnoticed.
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum Allow<'a> {
945    /// A hand-written rule on a generated class takes one of its properties.
946    Class {
947        /// The class, unprefixed as the stylesheet spells it.
948        class: &'a str,
949        /// The property both set.
950        property: &'a str,
951    },
952    /// A bare element rule takes a property the generated sheet sets on a class
953    /// that element carries.
954    Element {
955        /// The element the rule selects.
956        element: &'a str,
957        /// The generated class it reaches.
958        class: &'a str,
959        /// The property both set.
960        property: &'a str,
961    },
962}
963
964impl<'a> Allow<'a> {
965    /// See [`Allow::Class`].
966    pub const fn class(class: &'a str, property: &'a str) -> Self {
967        Self::Class { class, property }
968    }
969
970    /// See [`Allow::Element`].
971    pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
972        Self::Element {
973            element,
974            class,
975            property,
976        }
977    }
978}
979
980/// Fail the build if a hand-written stylesheet takes a property the generated
981/// one already sets, on the same class or on an element that carries it.
982///
983/// The generated sheet sits in `@layer makeover`. App CSS beats it whatever the
984/// specificity, either by being unlayered or by sitting in a layer the app's
985/// order statement puts after `makeover`, so an app declaration for a property
986/// makeover already sets does not merge with it: it wins, silently, and the
987/// design system's version of that component stops applying, and nothing looks.
988///
989/// # Two passes, because a rule can carry no class
990///
991/// The class pass compares an app `.button` against the generated `.button`.
992/// It reads rules by the classes in their selectors, so a rule with no class in
993/// it is invisible to it, and `button { color: var(--content) }` is exactly
994/// that: it sets the property the generated `.button` sets on every described
995/// act, `.button[data-tone="danger"]`'s tone included, which draws a
996/// destructive act like an ordinary one.
997///
998/// The element pass reads that case. `crate::vocabulary::ELEMENT_CLASSES`
999/// says which generated classes an element can carry (CSS cannot say it, and
1000/// the renderer can), and a bare element rule taking a property the design
1001/// system sets on one of those classes is the same defect as the class case.
1002///
1003/// Two things are not reported, both deliberately:
1004///
1005///   - A scoped rule (`.page button`). It reaches the elements inside one
1006///     region rather than every one of them, so whether it lands on a described
1007///     act depends on where that act renders. The certain case is the one this
1008///     reads.
1009///   - A property the app names on the class itself, from a rule that outranks
1010///     the element rule. Both are the app's and both sit in the same layer, so
1011///     that one contest is settled by specificity, and what reaches the design
1012///     system is the class rule, which the class pass has already reported
1013///     or reviewed. The rank matters: `.field` does not beat
1014///     `input[type="text"]`, and a handoff written as the weaker of the two is
1015///     a remedy that looks written and is not.
1016///
1017/// # Why properties and not class names
1018///
1019/// A shared class name is not by itself a divergence. An app `.badge` that sets
1020/// shape while the generated `.badge` sets colour is a correct split, and a
1021/// check on names would ask for it to be deleted. On properties, the split is
1022/// what the check enforces.
1023///
1024/// # The exception list
1025///
1026/// [`Allow::class`] names a `(class, property)` pair this app has reviewed and
1027/// kept.
1028/// Deciding which are legitimate here would need a selector matcher, and a check
1029/// that guesses wrong about specificity fails correct builds, so the app
1030/// declares it instead, the same shape as quasi-webview's `RENDERER_OWN`.
1031///
1032/// A reviewed pairing is a claim about who owns a property, and it expires when
1033/// the design system takes the property back.
1034///
1035/// [`Allow::element`] is the same thing one pass down: an element rule reaching
1036/// a generated class that has been read and kept.
1037///
1038/// The remedy is usually neither. A later layer can hand the property back
1039/// with `revert-layer`, which says "whatever the design system set here, keep
1040/// it" on the arms makeover actually paints, and that is a statement in the
1041/// stylesheet rather than a note in a build script. Handoffs are not reported by
1042/// either pass.
1043///
1044/// A pair that stops colliding fails too. A licence nobody is using is where
1045/// the next real collision lands and reads as company.
1046///
1047/// ```no_run
1048/// use makeover_webview::build::drift::{Allow, Vocabulary};
1049///
1050/// Vocabulary::in_frontend("frontend")
1051///     .generated(&["layout.css"])
1052///     // Reviewed: the card's shadow is the app's, on the hover arm only.
1053///     .allow(Allow::class("card", "box-shadow"))
1054///     .run(&makeover_webview::Emit::default());
1055/// ```
1056#[derive(Debug, Clone)]
1057#[must_use = "a check does nothing until it is run"]
1058pub struct Vocabulary<'a> {
1059    source: Source,
1060    generated: Vec<&'a str>,
1061    upstream: Vec<&'a str>,
1062    allowed: Vec<Allow<'a>>,
1063    beneath: Vec<&'a str>,
1064}
1065
1066impl<'a> Vocabulary<'a> {
1067    /// Every `.css` under `frontend/css`, recursively.
1068    pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
1069        Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
1070    }
1071
1072    /// A named list of stylesheets rather than a tree.
1073    ///
1074    /// For a frontend whose generated and hand-written sheets share a
1075    /// directory. Same trade as [`Breakpoints::files`]: the list is
1076    /// hand-maintained, and a stylesheet nobody adds to it is unchecked.
1077    pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
1078        Self::from(Source::files(paths))
1079    }
1080
1081    fn from(source: Source) -> Self {
1082        Self {
1083            source,
1084            generated: Vec::new(),
1085            upstream: Vec::new(),
1086            allowed: Vec::new(),
1087            beneath: Vec::new(),
1088        }
1089    }
1090
1091    /// Sheets this crate writes, which are skipped: the generated file setting
1092    /// a generated property is the point. Relative to `frontend/css` for
1093    /// [`in_frontend`](Self::in_frontend); matched against the end of each path
1094    /// for [`files`](Self::files).
1095    pub fn generated(mut self, names: &[&'a str]) -> Self {
1096        self.generated.extend_from_slice(names);
1097        self
1098    }
1099
1100    /// The text of stylesheets that sit between makeover and the app and own
1101    /// the classes they style as makeover owns its own.
1102    ///
1103    /// quasi-webview's `SCREEN_CSS` is the case. This crate cannot name it,
1104    /// since quasi builds on this crate and not the other way round, so the app
1105    /// that links both hands it in. A hand-written sheet is checked against
1106    /// makeover's sheet and every upstream sheet together. A file whose text
1107    /// *is* an upstream sheet, the copy an app serves from its frontend, is
1108    /// checked against makeover alone and never read as clashing with itself.
1109    pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
1110        self.upstream.extend_from_slice(sheets);
1111        self
1112    }
1113
1114    /// Cascade layers the app orders before `makeover`, by name.
1115    ///
1116    /// A rule inside one loses to every generated rule whatever its selector,
1117    /// so it cannot take a property from the design system and is not read.
1118    /// The usual case is a reset: `@layer reset, makeover, ...` with
1119    /// `* { margin: 0; padding: 0 }` inside `@layer reset { }` clears browser
1120    /// defaults without zeroing the generated spacing. The order itself lives
1121    /// in the app's markup, which this check does not read, so naming a layer
1122    /// here is the app stating that order.
1123    pub fn beneath(mut self, layers: &[&'a str]) -> Self {
1124        self.beneath.extend_from_slice(layers);
1125        self
1126    }
1127
1128    /// A reviewed exception. See [`Allow`].
1129    pub fn allow(mut self, allow: Allow<'a>) -> Self {
1130        self.allowed.push(allow);
1131        self
1132    }
1133
1134    /// Several reviewed exceptions, for a list the app keeps as a `const`.
1135    pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
1136        self.allowed.extend(allows);
1137        self
1138    }
1139
1140    /// Run the check against the stylesheet `opts` generates. Emits
1141    /// `cargo::rerun-if-changed` for every file it read.
1142    ///
1143    /// # Panics
1144    ///
1145    /// If a directory or listed file cannot be read, if any hand-written sheet
1146    /// takes a generated property without an [`Allow`], or if an [`Allow`] no
1147    /// longer collides. A build script has nowhere useful to return an error
1148    /// to, and an app quietly overriding its own design system is worse than a
1149    /// failed build.
1150    pub fn run(&self, opts: &Emit) {
1151        let classes: Vec<(&str, &str)> = self
1152            .allowed
1153            .iter()
1154            .filter_map(|allow| match *allow {
1155                Allow::Class { class, property } => Some((class, property)),
1156                Allow::Element { .. } => None,
1157            })
1158            .collect();
1159        let elements: Vec<(&str, &str, &str)> = self
1160            .allowed
1161            .iter()
1162            .filter_map(|allow| match *allow {
1163                Allow::Element {
1164                    element,
1165                    class,
1166                    property,
1167                } => Some((element, class, property)),
1168                Allow::Class { .. } => None,
1169            })
1170            .collect();
1171        match &self.source {
1172            Source::Frontend(frontend) => {
1173                let (css, files) = frontend_css(frontend);
1174                let files: Vec<PathBuf> = files
1175                    .into_iter()
1176                    .filter(|p| !self.generated.contains(&shown(p, Some(&css)).as_str()))
1177                    .collect();
1178                check_vocabulary_paths(
1179                    &files,
1180                    opts,
1181                    Some(frontend),
1182                    &self.upstream,
1183                    &self.beneath,
1184                    &classes,
1185                    &elements,
1186                );
1187            }
1188            Source::Files(paths) => {
1189                let files: Vec<PathBuf> = paths
1190                    .iter()
1191                    .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
1192                    .cloned()
1193                    .collect();
1194                check_vocabulary_paths(
1195                    &files,
1196                    opts,
1197                    None,
1198                    &self.upstream,
1199                    &self.beneath,
1200                    &classes,
1201                    &elements,
1202                );
1203            }
1204        }
1205    }
1206}
1207
1208/// The check itself. `root`, when given, is stripped from reported paths.
1209fn check_vocabulary_paths(
1210    paths: &[PathBuf],
1211    opts: &Emit,
1212    root: Option<&Path>,
1213    upstream: &[&str],
1214    beneath: &[&str],
1215    allowed: &[(&str, &str)],
1216    allowed_elements: &[(&str, &str, &str)],
1217) {
1218    let sheet = crate::stylesheet(opts);
1219    let makeover = crate::vocabulary::declarations_by_class(&sheet);
1220    // What a hand-written sheet answers to: makeover's classes and every
1221    // upstream sheet's, merged per class.
1222    let mut owned = makeover.clone();
1223    for sheet in upstream {
1224        for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
1225            owned.entry(class).or_default().extend(properties);
1226        }
1227    }
1228    // The same two views one level down: per arm rather than per class, so a
1229    // variant the generated sheet styles is not folded into its base.
1230    let makeover_variants = crate::vocabulary::variants_by_class(&sheet);
1231    let mut owned_variants = makeover_variants.clone();
1232    for sheet in upstream {
1233        for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
1234            let entry = owned_variants.entry(class).or_default();
1235            for (arm, properties) in arms {
1236                entry.entry(arm).or_default().extend(properties);
1237            }
1238        }
1239    }
1240    let mut cancels: Vec<String> = Vec::new();
1241    let mut clashes: Vec<String> = Vec::new();
1242    let mut seen: Vec<(String, String)> = Vec::new();
1243    let mut element_clashes: Vec<String> = Vec::new();
1244    let mut element_seen: Vec<(String, String, String)> = Vec::new();
1245
1246    for path in paths {
1247        rerun(path);
1248        let raw = read(path);
1249        let name = shown(path, root);
1250        // An upstream sheet served from the frontend answers to makeover alone:
1251        // measured against itself every declaration in it would clash.
1252        let (generated, variants) = if upstream.contains(&raw.as_str()) {
1253            (&makeover, &makeover_variants)
1254        } else {
1255            (&owned, &owned_variants)
1256        };
1257        // A layer ordered before `makeover` cannot beat it, so its rules are
1258        // not read. See `Vocabulary::beneath`.
1259        let raw = drop_layers(&raw, beneath);
1260        // Read the app's sheet the same way the crate reads its own, or the two
1261        // sides are not comparable.
1262        let local = crate::vocabulary::declarations_by_class(&raw);
1263        for (class, properties) in &local {
1264            let Some(theirs) = generated.get(class) else {
1265                continue;
1266            };
1267            for property in properties.intersection(theirs) {
1268                seen.push((class.clone(), property.clone()));
1269                if allowed.contains(&(class.as_str(), property.as_str())) {
1270                    continue;
1271                }
1272                clashes.push(format!("  {name}  .{class} {{ {property} }}"));
1273            }
1274        }
1275
1276        // The layer pass. A rule on the bare class reaches every element
1277        // carrying it, and from a later layer it beats every variant the design
1278        // system styles on that class, whatever their specificity. The class
1279        // pass cannot see this once the pair is reviewed: an `Allow` for
1280        // (button, color) written for one arm licensed cancelling
1281        // `[data-tone="danger"]` too, and goingson's destructive buttons lost
1282        // their tone that way. So no `Allow` silences it. The remedy is always
1283        // available: restate the variant, on its own arm, in the app's sheet.
1284        // A layer this sheet itself orders below makeover cannot cancel a
1285        // variant either, so it is read the way `beneath` is.
1286        let own_below = layers_ordered_below_makeover(&raw);
1287        let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
1288        let above = drop_layers(&raw, &own_below);
1289        let bare = crate::vocabulary::bare_declarations(&above);
1290        let restated = crate::vocabulary::mentions_by_arm(&raw);
1291        for (class, properties) in &bare {
1292            let Some(arms) = variants.get(class) else {
1293                continue;
1294            };
1295            for property in properties {
1296                let lost: Vec<&str> = arms
1297                    .iter()
1298                    .filter(|(arm, theirs)| {
1299                        theirs.contains(property)
1300                            && !restated
1301                                .get(arm.as_str())
1302                                .is_some_and(|mentioned| mentioned.contains(property))
1303                    })
1304                    .map(|(arm, _)| arm.as_str())
1305                    .collect();
1306                if lost.is_empty() {
1307                    continue;
1308                }
1309                cancels.push(format!(
1310                    "  {name}  .{class} {{ {property} }}  cancels  {}",
1311                    lost.iter()
1312                        .map(|arm| format!("{arm} {{ {property} }}"))
1313                        .collect::<Vec<_>>()
1314                        .join(", ")
1315                ));
1316            }
1317        }
1318
1319        // The second pass: a rule carrying no class at all, which the first one
1320        // cannot see. What the app says about the class itself settles the
1321        // pair, but only from a rule that outranks the element rule. Both are
1322        // the app's and both are in the same layer, so this one contest is
1323        // decided by specificity. `.field` does not beat `input[type="text"]`.
1324        let mentioned = crate::vocabulary::mentions_by_class(&raw);
1325        let by_element = crate::vocabulary::declarations_by_element(&raw);
1326        for (element, properties) in &by_element {
1327            for class in crate::vocabulary::classes_for_element(element, opts) {
1328                let Some(theirs) = generated.get(&class) else {
1329                    continue;
1330                };
1331                for (property, rank) in properties {
1332                    if !theirs.contains(property) {
1333                        continue;
1334                    }
1335                    // A tie goes to the class rule: at equal specificity the
1336                    // later rule wins, and a remedy is written after the rule
1337                    // it remedies.
1338                    let spoken_for = mentioned
1339                        .get(&class)
1340                        .and_then(|properties| properties.get(property))
1341                        .is_some_and(|theirs| theirs >= rank);
1342                    if spoken_for {
1343                        continue;
1344                    }
1345                    element_seen.push((element.clone(), class.clone(), property.clone()));
1346                    if allowed_elements.contains(&(
1347                        element.as_str(),
1348                        class.as_str(),
1349                        property.as_str(),
1350                    )) {
1351                        continue;
1352                    }
1353                    element_clashes.push(format!(
1354                        "  {name}  {element} {{ {property} }}  beats  .{class} {{ {property} }}"
1355                    ));
1356                }
1357            }
1358        }
1359    }
1360
1361    assert!(
1362        cancels.is_empty(),
1363        "{} hand-written rule(s) on a bare generated class set a property the \
1364         design system varies on that class by state, tone or context. The app \
1365         sheet sits in a later cascade layer than @layer makeover, and layers \
1366         resolve before specificity, so each of these beats every variant named \
1367         beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
1368         cancelled variant in the app's sheet on its own arm, after the bare rule \
1369         (`color: revert-layer` hands the design system's value back). That is \
1370         the remedy the cascade allows; an allowed-list entry does not cover \
1371         this, because the reviewed pair is the class and the variants are \
1372         what it silently takes.",
1373        cancels.len(),
1374        cancels.join("\n")
1375    );
1376
1377    assert!(
1378        clashes.is_empty(),
1379        "{} hand-written declaration(s) take a property the generated stylesheet, \
1380         or an upstream sheet named to this check, already sets on the same \
1381         class. App CSS wins over @layer makeover, whether by a later layer or \
1382         by being unlayered, so each of these wins over the design system \
1383         silently:\n{}\n\nDelete the declaration, or, if \
1384         it is a deliberate pairing on a different selector arm, add \
1385         (class, property) to this check's allowed list and say why beside it. \
1386         Count the consumers before deciding a divergence is worth keeping.",
1387        clashes.len(),
1388        clashes.join("\n")
1389    );
1390
1391    assert!(
1392        element_clashes.is_empty(),
1393        "{} hand-written element rule(s) take a property the generated \
1394         stylesheet sets on a class that element carries. App CSS wins over \
1395         @layer makeover, whether by a later layer or by being unlayered, so a \
1396         described component rendered on one of these elements loses the \
1397         design system's version of that property silently, which can draw a \
1398         destructive act like an ordinary one:\n{}\n\nHand the \
1399         property back on the arms makeover paints \
1400         (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1401         rule so it stops reaching described markup, or add \
1402         (element, class, property) to this check's allowed-elements list and \
1403         say why beside it.",
1404        element_clashes.len(),
1405        element_clashes.join("\n")
1406    );
1407
1408    assert_no_stale("allowed", allowed, |(class, property)| {
1409        seen.contains(&((*class).to_string(), (*property).to_string()))
1410    });
1411    assert_no_stale(
1412        "allowed-elements",
1413        allowed_elements,
1414        |(element, class, property)| {
1415            element_seen.contains(&(
1416                (*element).to_string(),
1417                (*class).to_string(),
1418                (*property).to_string(),
1419            ))
1420        },
1421    );
1422}
1423
1424/// Fail on an allowed-list entry that no longer collides with anything.
1425fn assert_no_stale<T: std::fmt::Debug>(list: &str, entries: &[T], used: impl Fn(&T) -> bool) {
1426    let stale: Vec<&T> = entries.iter().filter(|entry| !used(entry)).collect();
1427    assert!(
1428        stale.is_empty(),
1429        "the {list} list declares {stale:?}, which no longer collides with \
1430         anything. Delete the entries: an exception nobody is using is where the \
1431         next real collision lands and reads as company."
1432    );
1433}
1434
1435/// Whether `haystack` names `class` whole, rather than as part of a longer
1436/// name: `min-1` must not be read as used because `min-16` is, and `row` must
1437/// not be read as used because `row-flex` or `flex-row` is.
1438fn haystack_names(haystack: &str, class: &str) -> bool {
1439    haystack.match_indices(class).any(|(at, found)| {
1440        haystack[..at]
1441            .chars()
1442            .next_back()
1443            .is_none_or(|c| !is_ident(c))
1444            && haystack[at + found.len()..]
1445                .chars()
1446                .next()
1447                .is_none_or(|c| !is_ident(c))
1448    })
1449}
1450
1451/// Warn when the generated vocabulary has grown dead, and fail when it grows
1452/// deader than the recorded high-water mark.
1453///
1454/// A generated class no markup emits is a rule shipped to every user for
1455/// nothing. Some dead vocabulary is normal, so the count alone is not a
1456/// failure; what this checks is the direction. Recording the count in the
1457/// build script makes a change that worsens it fail rather than go unnoticed.
1458///
1459/// One-sided, the same shape as the MNW server's `frontend_globals` seal:
1460/// exceeding `high_water` fails, coming in under it warns and asks for the seal
1461/// to be lowered. A build that fails because dead CSS was deleted would teach
1462/// the wrong lesson.
1463///
1464/// `markup` is every file that can carry a class: templates, `.js`, `.html`,
1465/// and any Rust that writes markup. A class is counted as used if its name
1466/// appears whole in any of them, which is deliberately generous. A stricter reading
1467/// would need to know how each app builds its class strings, and a check that
1468/// guesses wrong fails a correct build.
1469///
1470/// # Panics
1471///
1472/// If a listed file cannot be read, or if more classes are unused than
1473/// `high_water`.
1474pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1475    let generated = crate::vocabulary::names(opts);
1476    let mut haystack = String::new();
1477    for path in markup {
1478        let path = path.as_ref();
1479        rerun(path);
1480        haystack.push_str(&read(path));
1481        haystack.push('\n');
1482    }
1483
1484    // A ladder is one piece of vocabulary, not one per rung. A column carries
1485    // whichever `min-N` rung its floor lands on and a table whichever `short-N`
1486    // `stacks-N`, `tiles-N` and `drop-*-N` rungs its floors need, so no site names every rung and counting each
1487    // would seal the dead count at the ladder's length. A ladder is used if any
1488    // rung is, and counted once if none is.
1489    const LADDERS: &[&str] = &[
1490        "min-",
1491        "short-",
1492        "stacks-",
1493        "tiles-",
1494        "drop-optional-",
1495        "drop-secondary-",
1496    ];
1497    let rung_of = |class: &str| -> Option<&'static str> {
1498        let name = class.strip_prefix(opts.class_prefix)?;
1499        LADDERS.iter().copied().find(|ladder| {
1500            name.strip_prefix(ladder)
1501                .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1502        })
1503    };
1504    let mut unused: Vec<String> = generated
1505        .iter()
1506        .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1507        .cloned()
1508        .collect();
1509    for ladder in LADDERS {
1510        let rungs: Vec<&String> = generated
1511            .iter()
1512            .filter(|class| rung_of(class) == Some(ladder))
1513            .collect();
1514        if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1515            unused.push(format!("{}{ladder}N", opts.class_prefix));
1516        }
1517    }
1518
1519    assert!(
1520        unused.len() <= high_water,
1521        "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1522         The vocabulary grew or the markup stopped using it:\n{}",
1523        unused.len(),
1524        generated.len(),
1525        high_water,
1526        unused
1527            .iter()
1528            .map(|c| format!("  .{c}"))
1529            .collect::<Vec<_>>()
1530            .join("\n")
1531    );
1532
1533    if unused.len() < high_water {
1534        println!(
1535            "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1536             lower the seal so it cannot grow back",
1537            unused.len(),
1538            high_water
1539        );
1540    }
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545    use super::*;
1546    use crate::test_support::scratch;
1547    use std::fmt::Write as _;
1548
1549    fn write(dir: &Path, name: &str, src: &str) {
1550        if let Some(parent) = dir.join(name).parent() {
1551            std::fs::create_dir_all(parent).unwrap();
1552        }
1553        std::fs::write(dir.join(name), src).unwrap();
1554    }
1555
1556    fn declaring() -> String {
1557        format!(
1558            "const {CONST_NAME} = '{}';\n",
1559            Density::Touch.media_condition()
1560        )
1561    }
1562
1563    #[test]
1564    fn the_crates_own_string_passes() {
1565        let dir = scratch("ok");
1566        write(&dir, "touch.js", &declaring());
1567        check_touch_density(&dir);
1568    }
1569
1570    #[test]
1571    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1572    fn a_drifted_literal_fails() {
1573        let dir = scratch("drift");
1574        write(&dir, "touch.js", &declaring());
1575        write(
1576            &dir,
1577            "haptics.js",
1578            &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1579        );
1580        check_touch_density(&dir);
1581    }
1582
1583    #[test]
1584    #[should_panic(expected = "device sniff")]
1585    fn the_sniff_cannot_come_back() {
1586        let dir = scratch("sniff");
1587        write(&dir, "touch.js", &declaring());
1588        write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1589        check_touch_density(&dir);
1590    }
1591
1592    #[test]
1593    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1594    fn a_frontend_that_states_nothing_fails() {
1595        let dir = scratch("empty");
1596        write(&dir, "app.js", "export const x = 1;\n");
1597        check_touch_density(&dir);
1598    }
1599
1600    #[test]
1601    fn a_use_site_is_not_a_declaration() {
1602        // The const is read far more often than it is declared, and a read
1603        // states no string. Counting one as a declaration would make the
1604        // `found > 0` assertion pass on a frontend that only imports it.
1605        let src =
1606            format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1607        assert!(
1608            touch_density_literals(&src).is_empty(),
1609            "{:?}",
1610            touch_density_literals(&src)
1611        );
1612    }
1613
1614    #[test]
1615    fn whitespace_and_backtick_literals_read() {
1616        let want = Density::Touch.media_condition();
1617        for src in [
1618            format!("const {CONST_NAME}='{want}';\n"),
1619            format!("const {CONST_NAME} = `{want}`;\n"),
1620            format!("const {CONST_NAME} =\n  '{want}';\n"),
1621        ] {
1622            let found = touch_density_literals(&src);
1623            assert_eq!(found.len(), 1, "src: {src:?}");
1624            assert_eq!(found[0].1, want, "src: {src:?}");
1625        }
1626    }
1627
1628    #[test]
1629    fn a_longer_identifier_is_not_the_const() {
1630        // A substring hit on the name must not pass as the declaration.
1631        let want = Density::Touch.media_condition();
1632        for src in [
1633            format!("const OLD_{CONST_NAME} = '{want}';\n"),
1634            format!("const {CONST_NAME}_OLD = '{want}';\n"),
1635        ] {
1636            assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1637        }
1638    }
1639
1640    #[test]
1641    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1642    fn a_commented_out_declaration_is_not_a_declaration() {
1643        // The real const can be deleted and a `//`-prefixed copy left behind;
1644        // the check must not count that as a declaration.
1645        let dir = scratch("commented-decl");
1646        write(
1647            &dir,
1648            "touch.js",
1649            &format!("// {}\n", declaring().trim_end()),
1650        );
1651        check_touch_density(&dir);
1652    }
1653
1654    #[test]
1655    fn a_sniff_named_in_a_comment_is_not_a_use() {
1656        // The word appears, but only in prose about why the check exists.
1657        let dir = scratch("sniff-comment");
1658        write(&dir, "touch.js", &declaring());
1659        write(
1660            &dir,
1661            "note.js",
1662            "// never read ontouchstart or maxTouchPoints here\n",
1663        );
1664        check_touch_density(&dir);
1665    }
1666
1667    #[test]
1668    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1669    fn a_wrong_literal_without_spaces_is_caught() {
1670        // The old reader required ` = ` and skipped this spelling entirely.
1671        let dir = scratch("loose-drift");
1672        write(&dir, "touch.js", &declaring());
1673        write(
1674            &dir,
1675            "haptics.js",
1676            &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1677        );
1678        check_touch_density(&dir);
1679    }
1680
1681    #[test]
1682    fn nested_files_are_read() {
1683        // The server keeps its scripts in subdirectories, and the nested half
1684        // is the half most likely to be a copy.
1685        let dir = scratch("nested");
1686        write(&dir, "touch.js", &declaring());
1687        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1688        let files = js_files(&dir);
1689        assert_eq!(files.len(), 2);
1690    }
1691
1692    #[test]
1693    fn a_non_js_file_is_ignored() {
1694        let dir = scratch("nonjs");
1695        write(&dir, "touch.js", &declaring());
1696        write(&dir, "styles.css", "body { }\n");
1697        assert_eq!(js_files(&dir).len(), 1);
1698    }
1699
1700    fn frontend(name: &str) -> PathBuf {
1701        let dir = scratch(name);
1702        std::fs::create_dir_all(dir.join("css")).unwrap();
1703        std::fs::create_dir_all(dir.join("js")).unwrap();
1704        dir
1705    }
1706
1707    /// A width every size class agrees is a boundary.
1708    fn boundary() -> u16 {
1709        SizeClass::Medium.min_px()
1710    }
1711
1712    #[test]
1713    fn the_crates_own_boundaries_pass() {
1714        let dir = frontend("bp-ok");
1715        write(
1716            &dir,
1717            "css/styles.css",
1718            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1719        );
1720        Breakpoints::in_frontend(&dir).run();
1721    }
1722
1723    #[test]
1724    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1725    fn a_stale_css_width_fails() {
1726        let dir = frontend("bp-css");
1727        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1728        Breakpoints::in_frontend(&dir).run();
1729    }
1730
1731    #[test]
1732    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1733    fn a_stale_js_width_fails() {
1734        let dir = frontend("bp-js");
1735        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1736        Breakpoints::in_frontend(&dir).run();
1737    }
1738
1739    #[test]
1740    fn a_declared_tuning_width_passes() {
1741        let dir = frontend("bp-tuning");
1742        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1743        Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1744    }
1745
1746    #[test]
1747    fn a_container_query_on_a_shell_boundary_passes() {
1748        // `@container screen (width < 600px)` is a media query in disguise: the
1749        // `screen` container tracks the shell, so its width must be a SizeClass
1750        // boundary. 600 is one.
1751        let dir = frontend("bp-container-ok");
1752        write(
1753            &dir,
1754            "css/styles.css",
1755            &format!("@container screen (width < {}px) {{ }}\n", boundary()),
1756        );
1757        Breakpoints::in_frontend(&dir).run();
1758    }
1759
1760    #[test]
1761    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1762    fn a_stale_container_width_fails() {
1763        let dir = frontend("bp-container-stale");
1764        write(
1765            &dir,
1766            "css/styles.css",
1767            "@container screen (width < 768px) { }\n",
1768        );
1769        Breakpoints::in_frontend(&dir).run();
1770    }
1771
1772    #[test]
1773    fn a_container_inline_size_condition_is_not_a_width_boundary() {
1774        // An inline-size container query on a component (`tab-strip`) is not a
1775        // shell width and reads no `width` feature, so it is left alone.
1776        let dir = frontend("bp-container-inline");
1777        write(
1778            &dir,
1779            "css/styles.css",
1780            "@container tab-strip (inline-size < 36rem) { }\n",
1781        );
1782        Breakpoints::in_frontend(&dir).run();
1783    }
1784
1785    #[test]
1786    fn a_width_in_a_comment_is_prose() {
1787        // The note explaining which breakpoint used to be here is not a rule,
1788        // and failing a build on documentation would teach people to delete it.
1789        let dir = frontend("bp-comment");
1790        write(
1791            &dir,
1792            "css/styles.css",
1793            "/* was @media (max-width: 768px) until the size classes landed */\n",
1794        );
1795        Breakpoints::in_frontend(&dir).run();
1796    }
1797
1798    #[test]
1799    fn an_unparenthesized_width_is_not_a_breakpoint() {
1800        // A JS string building an inline style states `max-width: 320px` with
1801        // no parentheses. It is a declaration, not a query, and the first
1802        // version of this check failed the build on one.
1803        let dir = frontend("bp-inline");
1804        write(
1805            &dir,
1806            "js/style.js",
1807            "el.style.cssText = 'max-width: 320px; display: block';\n",
1808        );
1809        Breakpoints::in_frontend(&dir).run();
1810    }
1811
1812    #[test]
1813    fn nested_css_is_read() {
1814        // Same argument as the touch check: the nested half is the half most
1815        // likely to be a copy.
1816        let dir = frontend("bp-nested");
1817        write(
1818            &dir,
1819            "css/screens/detail.css",
1820            "@media (max-width: 768px) { }\n",
1821        );
1822        let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1823        assert!(found.is_err(), "a nested stylesheet must be scanned");
1824    }
1825
1826    #[test]
1827    fn a_css_only_frontend_scans_without_a_js_directory() {
1828        // A CSS-only frontend ships no `js/`, and the recommended `in_frontend`
1829        // form must scan the stylesheets rather than fail on the absent tree.
1830        let dir = scratch("bp-css-only");
1831        write(
1832            &dir,
1833            "css/styles.css",
1834            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1835        );
1836        Breakpoints::in_frontend(&dir).run();
1837    }
1838
1839    #[test]
1840    fn a_named_list_is_checked() {
1841        let dir = frontend("bp-list");
1842        write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1843        let listed = dir.join("css/style.css");
1844        let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1845        let msg = err.downcast_ref::<String>().expect("String payload");
1846        assert!(msg.contains("style.css:1"), "got: {msg}");
1847    }
1848
1849    #[test]
1850    #[should_panic(expected = "read ")]
1851    fn a_listed_file_that_is_gone_fails() {
1852        // The list is hand-maintained, so a path that stopped existing is a
1853        // check quietly covering less than it claims. Louder than skipping it.
1854        let dir = frontend("bp-missing");
1855        Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1856    }
1857
1858    #[test]
1859    fn a_listed_js_file_is_parsed_as_script() {
1860        // The unparenthesized-declaration rule is what separates the two, and
1861        // picking the parser off the extension is the whole difference.
1862        let dir = frontend("bp-list-js");
1863        write(
1864            &dir,
1865            "js/style.js",
1866            "el.style.cssText = 'max-width: 320px';\n",
1867        );
1868        Breakpoints::files(&[dir.join("js/style.js")]).run();
1869    }
1870
1871    #[test]
1872    fn the_error_names_the_file_and_line() {
1873        let dir = frontend("bp-message");
1874        write(
1875            &dir,
1876            "css/styles.css",
1877            "body { }\n@media (max-width: 768px) { }\n",
1878        );
1879        let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1880        let msg = err
1881            .downcast_ref::<String>()
1882            .expect("panic payload is a String");
1883        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1884    }
1885
1886    #[test]
1887    fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1888        // quasi-webview's sheet owns `.run`'s gap the way makeover owns
1889        // `.card`'s shadow, and an app redrawing it wins silently the same way.
1890        let upstream = ".tile-set { gap: 4px; }\n";
1891        let dir = scratch("vocab-upstream");
1892        write(&dir, "css/quasi.css", upstream);
1893        write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1894        let err = std::panic::catch_unwind(|| {
1895            Vocabulary::in_frontend(&dir)
1896                .upstream(&[upstream])
1897                .run(&Emit::default());
1898        })
1899        .unwrap_err();
1900        let msg = err
1901            .downcast_ref::<String>()
1902            .expect("panic payload is a String");
1903        assert!(
1904            msg.contains("css/styles.css  .tile-set { gap }"),
1905            "got: {msg}"
1906        );
1907        assert!(
1908            !msg.contains("css/quasi.css"),
1909            "the served copy clashed with itself: {msg}"
1910        );
1911
1912        // Without the upstream sheet named, the same tree passes, which is the
1913        // blind spot this closes.
1914        Vocabulary::in_frontend(&dir).run(&Emit::default());
1915    }
1916
1917    #[test]
1918    fn a_rule_restating_a_generated_class_fails_and_names_it() {
1919        let dir = scratch("vocab-clash");
1920        // `.card` is makeover's. An app rule for it beats the generated one,
1921        // because app CSS is unlayered and the generated sheet is not.
1922        write(
1923            &dir,
1924            "css/styles.css",
1925            "body { color: red; }\n.card { box-shadow: none; }\n",
1926        );
1927        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1928            .unwrap_err();
1929        let msg = err
1930            .downcast_ref::<String>()
1931            .expect("panic payload is a String");
1932        assert!(msg.contains(".card"), "got: {msg}");
1933        assert!(msg.contains("box-shadow"), "got: {msg}");
1934        assert!(msg.contains("css/styles.css"), "got: {msg}");
1935    }
1936
1937    #[test]
1938    fn an_app_class_of_its_own_is_left_alone() {
1939        let dir = scratch("vocab-clean");
1940        write(
1941            &dir,
1942            "css/styles.css",
1943            ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1944        );
1945        Vocabulary::in_frontend(&dir).run(&Emit::default());
1946    }
1947
1948    #[test]
1949    fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1950        let dir = scratch("vocab-generated");
1951        let opts = Emit::default();
1952        write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1953        // Without the skip this is the loudest failure possible: every class in
1954        // the vocabulary, reported as a clash with the vocabulary.
1955        Vocabulary::in_frontend(&dir)
1956            .generated(&["layout.css"])
1957            .run(&opts);
1958    }
1959
1960    #[test]
1961    fn a_prefixed_app_is_checked_against_its_own_prefix() {
1962        let dir = scratch("vocab-prefix");
1963        let opts = Emit::new().class_prefix("mo-");
1964        // Bare `.card` is the app's own class once the generated sheet writes
1965        // `.mo-card`, so this has to pass.
1966        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1967        Vocabulary::in_frontend(&dir).run(&opts);
1968
1969        let dir = scratch("vocab-prefix-clash");
1970        write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1971        assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1972    }
1973
1974    #[test]
1975    fn a_class_shared_without_a_shared_property_is_left_alone() {
1976        let dir = scratch("vocab-additive");
1977        // The generated `.cell-value` sets only the text colour, so an app
1978        // setting its shape shares the class and takes nothing from it.
1979        write(
1980            &dir,
1981            "css/styles.css",
1982            ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1983        );
1984        Vocabulary::in_frontend(&dir).run(&Emit::default());
1985    }
1986
1987    #[test]
1988    fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1989        let dir = scratch("vocab-allowed");
1990        write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
1991        Vocabulary::in_frontend(&dir)
1992            .allow(Allow::class("badge", "padding"))
1993            .run(&Emit::default());
1994
1995        // The same licence against a sheet that no longer collides has to fail,
1996        // or the list only ever grows.
1997        let dir = scratch("vocab-allowed-stale");
1998        write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1999        let err = std::panic::catch_unwind(|| {
2000            Vocabulary::in_frontend(&dir)
2001                .allow(Allow::class("badge", "padding"))
2002                .run(&Emit::default());
2003        })
2004        .unwrap_err();
2005        let msg = err
2006            .downcast_ref::<String>()
2007            .expect("panic payload is a String");
2008        assert!(msg.contains("no longer collides"), "got: {msg}");
2009    }
2010
2011    #[test]
2012    fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2013        let dir = scratch("vocab-element");
2014        // The defect that shipped for months: no class in the selector, so the
2015        // class pass sees nothing, and every described act in the app takes the
2016        // app's bevel instead of the design system's.
2017        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2018        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2019            .unwrap_err();
2020        let msg = err
2021            .downcast_ref::<String>()
2022            .expect("panic payload is a String");
2023        assert!(msg.contains("select {"), "got: {msg}");
2024        assert!(msg.contains(".field"), "got: {msg}");
2025        assert!(msg.contains("box-shadow"), "got: {msg}");
2026        assert!(msg.contains("css/styles.css"), "got: {msg}");
2027    }
2028
2029    #[test]
2030    fn a_universal_reset_after_the_design_system_is_caught() {
2031        // goingson and MNW both opened with `* { margin: 0; padding: 0 }` in a
2032        // layer after `makeover`, which zeroed every generated padding. Nothing
2033        // said so until a render showed cells with no padding.
2034        let dir = scratch("vocab-universal");
2035        write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2036        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2037            .unwrap_err();
2038        let msg = err
2039            .downcast_ref::<String>()
2040            .expect("panic payload is a String");
2041        assert!(msg.contains("* {"), "got: {msg}");
2042        assert!(msg.contains("padding"), "got: {msg}");
2043    }
2044
2045    #[test]
2046    fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2047        let dir = scratch("vocab-universal-beneath");
2048        write(
2049            &dir,
2050            "css/styles.css",
2051            "/* { */ @layer reset {\n  * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2052        );
2053        Vocabulary::in_frontend(&dir)
2054            .beneath(&["reset"])
2055            .run(&Emit::default());
2056    }
2057
2058    #[test]
2059    fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2060        let dir = scratch("vocab-important-beneath");
2061        // `!important` inverts layer order, so an important declaration in a
2062        // layer beneath makeover beats every later layer. The reduced-motion
2063        // idiom writes exactly this shape, and dropping the layer whole would
2064        // hide the override.
2065        write(
2066            &dir,
2067            "css/styles.css",
2068            "@layer reset {\n  .field { box-shadow: none !important; }\n}\n",
2069        );
2070        let err = std::panic::catch_unwind(|| {
2071            Vocabulary::in_frontend(&dir)
2072                .beneath(&["reset"])
2073                .run(&Emit::default());
2074        })
2075        .unwrap_err();
2076        let msg = err.downcast_ref::<String>().expect("a String payload");
2077        assert!(msg.contains("box-shadow"), "got: {msg}");
2078    }
2079
2080    #[test]
2081    fn a_layer_not_named_beneath_is_still_read() {
2082        let dir = scratch("vocab-universal-other-layer");
2083        write(
2084            &dir,
2085            "css/styles.css",
2086            "@layer base {\n  * { padding: 0; }\n}\n",
2087        );
2088        let err = std::panic::catch_unwind(|| {
2089            Vocabulary::in_frontend(&dir)
2090                .beneath(&["reset"])
2091                .run(&Emit::default());
2092        })
2093        .unwrap_err();
2094        let msg = err.downcast_ref::<String>().expect("a String payload");
2095        assert!(msg.contains("* {"), "got: {msg}");
2096    }
2097
2098    #[test]
2099    fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2100        let dir = scratch("vocab-universal-box");
2101        write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2102        Vocabulary::in_frontend(&dir).run(&Emit::default());
2103    }
2104
2105    #[test]
2106    fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2107        let dir = scratch("vocab-element-handoff");
2108        // What a consumer writes instead of an exception: the element rule
2109        // stays, and a later layer gives the property back on the class. The
2110        // check has to read that as settled or the remedy fails the build it
2111        // was written to fix.
2112        write(
2113            &dir,
2114            "css/styles.css",
2115            "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2116        );
2117        Vocabulary::in_frontend(&dir).run(&Emit::default());
2118    }
2119
2120    #[test]
2121    fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2122        let dir = scratch("vocab-element-spoken-for");
2123        // Within the app's own sheet the class rule outranks the bare element
2124        // rule, so what reaches the design system is `.button`, not `button`.
2125        // The class pass has that pair, here as a reviewed one, and
2126        // reporting it twice would ask for two remedies for one collision.
2127        write(
2128            &dir,
2129            "css/styles.css",
2130            "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2131             .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2132             .field[aria-invalid=\"true\"] {\n    box-shadow: revert-layer;\n}\n",
2133        );
2134        Vocabulary::in_frontend(&dir)
2135            .allow(Allow::class("field", "box-shadow"))
2136            .run(&Emit::default());
2137    }
2138
2139    #[test]
2140    fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2141        let dir = scratch("vocab-element-weak-handoff");
2142        // The shape that reads as fixed and is not: both rules are the app's
2143        // and both are in the same layer, so the state on the element rule
2144        // decides, and the described field keeps the app's sunken fill.
2145        write(
2146            &dir,
2147            "css/styles.css",
2148            "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2149        );
2150        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2151            .unwrap_err();
2152        let msg = err
2153            .downcast_ref::<String>()
2154            .expect("panic payload is a String");
2155        assert!(msg.contains(".field"), "got: {msg}");
2156
2157        // Written to win, it is.
2158        let dir = scratch("vocab-element-strong-handoff");
2159        write(
2160            &dir,
2161            "css/styles.css",
2162            "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2163        );
2164        Vocabulary::in_frontend(&dir).run(&Emit::default());
2165    }
2166
2167    #[test]
2168    fn a_scoped_rule_is_not_read_as_an_element_rule() {
2169        let dir = scratch("vocab-element-scoped");
2170        // It reaches the buttons inside one region rather than every button, so
2171        // whether it lands on a described act depends on where that act
2172        // renders. Failing the build on a guess is the worse error.
2173        write(
2174            &dir,
2175            "css/styles.css",
2176            ".wizard select { box-shadow: none; }\n",
2177        );
2178        Vocabulary::in_frontend(&dir).run(&Emit::default());
2179    }
2180
2181    #[test]
2182    fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2183        let dir = scratch("vocab-element-unpaired");
2184        // `.card` is a container: no generated class sits on a `<footer>`, so
2185        // there is nothing for this rule to take.
2186        write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2187        Vocabulary::in_frontend(&dir).run(&Emit::default());
2188    }
2189
2190    #[test]
2191    fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2192        // goingson's case: a property on `.button` in the app layer beat
2193        // every `.button[data-tone]` arm, and the reviewed pair is what kept
2194        // the class pass quiet about it. The tone paints the ground now, so
2195        // the property that cancels it is `background`.
2196        let dir = scratch("vocab-cancel-tone");
2197        write(
2198            &dir,
2199            "css/styles.css",
2200            ".button { background: var(--surface-raised); }\n",
2201        );
2202        let err = std::panic::catch_unwind(|| {
2203            Vocabulary::in_frontend(&dir)
2204                .allow(Allow::class("button", "background"))
2205                .run(&Emit::default());
2206        })
2207        .unwrap_err();
2208        let msg = err
2209            .downcast_ref::<String>()
2210            .expect("panic payload is a String");
2211        assert!(
2212            msg.contains(".button { background }  cancels"),
2213            "got: {msg}"
2214        );
2215        assert!(
2216            msg.contains(".button[data-tone=\"danger\"] { background }"),
2217            "the message names the variant it cancels: {msg}"
2218        );
2219        assert!(msg.contains("restate"), "and the remedy: {msg}");
2220    }
2221
2222    #[test]
2223    fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2224        // The remedy the message gives, spelled with single quotes and loose
2225        // whitespace to show the arms compare as selectors rather than bytes.
2226        let opts = Emit::default();
2227        let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2228        let mut css = String::from(".button { background: var(--surface-raised); }\n");
2229        for (arm, properties) in &variants["button"] {
2230            if properties.contains("background") {
2231                let loose = arm.replace('"', "'").replace(',', " , ");
2232                let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2233            }
2234        }
2235        let dir = scratch("vocab-cancel-restated");
2236        write(&dir, "css/styles.css", &css);
2237        Vocabulary::in_frontend(&dir)
2238            .allow(Allow::class("button", "background"))
2239            .run(&opts);
2240    }
2241
2242    #[test]
2243    fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2244        // Neither reaches every `.button`, so whether one lands on a toned act
2245        // depends on markup the check does not read.
2246        let dir = scratch("vocab-cancel-scoped");
2247        write(
2248            &dir,
2249            "css/styles.css",
2250            ".toolbar .button { color: var(--content); }\n\
2251             .button.quiet { color: var(--content); }\n",
2252        );
2253        // The class pass still reads both as `.button` declarations; the pair
2254        // is reviewed here so only the layer pass is under test.
2255        Vocabulary::in_frontend(&dir)
2256            .allow(Allow::class("button", "color"))
2257            .run(&Emit::default());
2258    }
2259
2260    #[test]
2261    fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2262        // quasi-webview's SCREEN_CSS, as an app serves it: its own statement
2263        // puts `quasi` under `makeover`, so its `.button { cursor }` loses to
2264        // the disabled arm rather than cancelling it.
2265        let dir = scratch("vocab-cancel-own-order");
2266        write(
2267            &dir,
2268            "css/quasi.css",
2269            "@layer reset, quasi, makeover;\n\
2270             @layer quasi {\n    .button { cursor: pointer; }\n}\n",
2271        );
2272        Vocabulary::in_frontend(&dir)
2273            .allow(Allow::class("button", "cursor"))
2274            .run(&Emit::default());
2275
2276        // The same rule in a layer the statement puts above makeover does.
2277        let dir = scratch("vocab-cancel-own-order-above");
2278        write(
2279            &dir,
2280            "css/app.css",
2281            "@layer reset, makeover, app;\n\
2282             @layer app {\n    .button { cursor: pointer; }\n}\n",
2283        );
2284        let err = std::panic::catch_unwind(|| {
2285            Vocabulary::in_frontend(&dir)
2286                .allow(Allow::class("button", "cursor"))
2287                .run(&Emit::default());
2288        })
2289        .unwrap_err();
2290        let msg = err
2291            .downcast_ref::<String>()
2292            .expect("panic payload is a String");
2293        assert!(msg.contains(".button { cursor }  cancels"), "got: {msg}");
2294    }
2295
2296    #[test]
2297    fn a_handoff_on_the_bare_class_cancels_nothing() {
2298        let dir = scratch("vocab-cancel-handoff");
2299        write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2300        Vocabulary::in_frontend(&dir).run(&Emit::default());
2301    }
2302
2303    #[test]
2304    fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2305        let dir = scratch("vocab-element-allowed");
2306        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2307        Vocabulary::in_frontend(&dir)
2308            .allow(Allow::element("select", "field", "box-shadow"))
2309            .run(&Emit::default());
2310
2311        // And the same licence against a sheet that no longer collides fails,
2312        // for the reason the class list's does.
2313        let dir = scratch("vocab-element-allowed-stale");
2314        write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2315        let err = std::panic::catch_unwind(|| {
2316            Vocabulary::in_frontend(&dir)
2317                .allow(Allow::element("select", "field", "box-shadow"))
2318                .run(&Emit::default());
2319        })
2320        .unwrap_err();
2321        let msg = err
2322            .downcast_ref::<String>()
2323            .expect("panic payload is a String");
2324        assert!(msg.contains("no longer collides"), "got: {msg}");
2325    }
2326
2327    /// How many rung classes the vocabulary holds, and on how many ladders.
2328    fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2329        let mut rungs = 0;
2330        let mut ladders = 0;
2331        for ladder in [
2332            "min-",
2333            "short-",
2334            "stacks-",
2335            "tiles-",
2336            "drop-optional-",
2337            "drop-secondary-",
2338        ] {
2339            let here = names
2340                .iter()
2341                .filter(|c| {
2342                    c.strip_prefix(ladder)
2343                        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2344                })
2345                .count();
2346            rungs += here;
2347            ladders += usize::from(here > 0);
2348        }
2349        (rungs, ladders)
2350    }
2351
2352    #[test]
2353    fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2354        let dir = scratch("vocab-seal");
2355        let opts = Emit::default();
2356        let names = crate::vocabulary::names(&opts);
2357        let (rungs, ladders) = ladders(&names);
2358        assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2359        // Markup naming nothing: every class is unused, and each ladder counts
2360        // once rather than once a rung.
2361        let dead = names.len() - rungs + ladders;
2362        write(&dir, "index.html", "<div></div>\n");
2363        let markup = [dir.join("index.html")];
2364
2365        check_vocabulary_use(&markup, &opts, dead);
2366        assert!(
2367            std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2368            "a vocabulary deader than the seal has to fail"
2369        );
2370    }
2371
2372    #[test]
2373    fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2374        // A column carries whichever rung its floor lands on, so a site naming
2375        // one rung is using the ladder, and one naming none has one dead name.
2376        let opts = Emit::default();
2377        let names = crate::vocabulary::names(&opts);
2378        let (rungs, ladders) = ladders(&names);
2379        let dead = names.len() - rungs + ladders;
2380
2381        let dir = scratch("vocab-ladder");
2382        write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2383        let one_rung = [dir.join("index.html")];
2384        // `cell` is named too, so two fewer than a page naming nothing.
2385        check_vocabulary_use(&one_rung, &opts, dead - 2);
2386
2387        // A longer rung does not name a shorter one it starts with.
2388        write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2389        assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2390        assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2391    }
2392
2393    #[test]
2394    fn a_class_is_named_whole_on_both_sides() {
2395        assert!(haystack_names("class=\"row\"", "row"));
2396        assert!(!haystack_names("class=\"row-flex\"", "row"));
2397        assert!(!haystack_names("class=\"flex-row\"", "row"));
2398        assert!(haystack_names("el.classList.add('row')", "row"));
2399    }
2400
2401    #[test]
2402    fn range_syntax_and_units_are_read() {
2403        assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2404        assert_eq!(
2405            media_widths(" (600px <= width < 840px)"),
2406            [Width::Px(600), Width::Px(840)]
2407        );
2408        assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2409        assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2410        assert_eq!(
2411            media_widths(" (max-width: 599.98px)"),
2412            [Width::Other("599.98px".into())]
2413        );
2414        assert_eq!(
2415            media_widths(" (width < 50vw)"),
2416            [Width::Other("50vw".into())]
2417        );
2418        let found = media_widths(" (min-height: 600px)");
2419        assert!(found.is_empty(), "{found:?}");
2420        let found = media_widths(" (max-device-width: 600px)");
2421        assert!(found.is_empty(), "{found:?}");
2422        let found = media_widths(" (hover: hover) and (pointer: fine)");
2423        assert!(found.is_empty(), "{found:?}");
2424    }
2425
2426    #[test]
2427    fn every_size_class_condition_passes_its_own_check() {
2428        let dir = frontend("bp-classes");
2429        let mut css = String::new();
2430        for class in SizeClass::all() {
2431            let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2432        }
2433        write(&dir, "css/styles.css", &css);
2434        write(
2435            &dir,
2436            "js/shell.js",
2437            &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2438        );
2439        Breakpoints::in_frontend(&dir).run();
2440    }
2441
2442    #[test]
2443    #[should_panic(expected = "48em")]
2444    fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2445        let dir = frontend("bp-em");
2446        write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2447        Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2448    }
2449
2450    #[test]
2451    #[should_panic(expected = "(width < 768px)")]
2452    fn a_stale_range_width_fails() {
2453        let dir = frontend("bp-range");
2454        write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2455        Breakpoints::in_frontend(&dir).run();
2456    }
2457
2458    #[test]
2459    #[should_panic(expected = "599px")]
2460    fn the_old_inclusive_compact_bound_is_stale() {
2461        let dir = frontend("bp-599");
2462        write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2463        Breakpoints::in_frontend(&dir).run();
2464    }
2465
2466    #[test]
2467    #[should_panic(expected = "(max-width: 600px)")]
2468    fn an_inclusive_bound_at_a_class_edge_is_stale() {
2469        // The number is a boundary, so a check that reads numbers alone lets
2470        // it through. At exactly 600px it agrees with Compact and the crate
2471        // says Medium.
2472        let dir = frontend("bp-600-inclusive");
2473        write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2474        Breakpoints::in_frontend(&dir).run();
2475    }
2476
2477    #[test]
2478    #[should_panic(expected = "(width <= 600px)")]
2479    fn an_inclusive_range_upper_bound_is_stale() {
2480        let dir = frontend("bp-le-600");
2481        write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2482        Breakpoints::in_frontend(&dir).run();
2483    }
2484
2485    #[test]
2486    fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2487        let dir = frontend("bp-edges-ok");
2488        write(
2489            &dir,
2490            "css/styles.css",
2491            "@media (min-width: 600px) { }\n\
2492             @media (600px <= width) { }\n\
2493             @media (width >= 840px) { }\n",
2494        );
2495        Breakpoints::in_frontend(&dir).run();
2496    }
2497
2498    #[test]
2499    fn a_tuning_max_width_is_left_alone() {
2500        // No class sits on 1400px, so an inclusive bound there has nothing to
2501        // disagree with.
2502        let dir = frontend("bp-tuning-max");
2503        write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2504        Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2505    }
2506
2507    #[test]
2508    fn a_call_in_script_is_not_a_feature() {
2509        let found = js_widths("const w = Math.max(width, 600);");
2510        assert!(found.is_empty(), "{found:?}");
2511        let found = js_widths("el.style.cssText = 'max-width: 320px';");
2512        assert!(found.is_empty(), "{found:?}");
2513        assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2514    }
2515
2516    #[test]
2517    fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2518        let css = "/*/ @media (max-width: 768px) { } */ a { }";
2519        let stripped = blank(css, Lang::Css);
2520        assert!(!stripped.contains("768"), "{stripped:?}");
2521        assert!(stripped.contains("a { }"));
2522        assert_eq!(stripped.len(), css.len());
2523    }
2524
2525    #[test]
2526    fn a_comment_opener_inside_a_string_is_text() {
2527        // Read as a comment, the `/*` would blank the query after it.
2528        let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2529        let src = blank(css, Lang::Css);
2530        assert_eq!(media_conditions(&src).len(), 1);
2531        assert!(src.contains("768"));
2532
2533        let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2534        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2535    }
2536
2537    #[test]
2538    fn a_regex_literal_does_not_open_a_comment() {
2539        // `/\/*$/` matches trailing slashes; read as a block comment it blanks
2540        // every query after it.
2541        let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2542        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2543    }
2544
2545    #[test]
2546    fn a_division_is_not_a_regex_literal() {
2547        // A `/` after a value divides; it must not swallow the line as regex.
2548        let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2549        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2550    }
2551
2552    #[test]
2553    fn a_css_bad_string_ends_at_a_newline() {
2554        // An unterminated string is a parse error that ends at the newline;
2555        // read as an open string it swallows every rule after it.
2556        let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2557        let src = blank(css, Lang::Css);
2558        assert_eq!(media_conditions(&src).len(), 1);
2559        assert!(src.contains("768"));
2560    }
2561
2562    #[test]
2563    fn an_escaped_quote_does_not_open_a_string() {
2564        let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2565        let src = blank(css, Lang::Css);
2566        assert_eq!(media_conditions(&src).len(), 1);
2567    }
2568
2569    #[test]
2570    fn an_unquoted_url_does_not_open_a_comment() {
2571        let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2572        let src = blank(css, Lang::Css);
2573        assert_eq!(media_conditions(&src).len(), 1);
2574        // A quoted url is already text, and a comment after it still goes.
2575        let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2576                   @media (max-width: 768px) { }\n";
2577        let src = blank(css, Lang::Css);
2578        assert!(!src.contains("gone"), "{src:?}");
2579        assert_eq!(media_conditions(&src).len(), 1);
2580    }
2581
2582    #[test]
2583    fn a_leading_dot_length_is_read() {
2584        // `.5em` is 8px, not the 80px the digits alone would give.
2585        assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2586        assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2587    }
2588
2589    #[test]
2590    fn an_at_media_keyword_reads_in_any_case() {
2591        let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2592        assert_eq!(conditions.len(), 1);
2593        assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2594    }
2595
2596    #[test]
2597    fn a_width_nested_in_a_function_is_read() {
2598        assert_eq!(
2599            media_widths("(max-width: calc(768px))"),
2600            vec![Width::Px(768)]
2601        );
2602        // Arithmetic inside calc is read as its terms; the sum is not a clean
2603        // boundary, so nothing is silently skipped.
2604        assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2605    }
2606
2607    #[test]
2608    fn a_newline_does_not_reset_an_open_group() {
2609        assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2610    }
2611
2612    #[test]
2613    fn custom_media_and_import_carry_media_queries() {
2614        let css = "@custom-media --narrow (max-width: 768px);\n\
2615                   @import url(x.css) (max-width: 768px);\n";
2616        let conditions = media_conditions(css);
2617        assert_eq!(conditions.len(), 2);
2618        for (_, condition) in conditions {
2619            assert!(
2620                media_widths(condition).contains(&Width::Px(768)),
2621                "unread: {condition:?}"
2622            );
2623        }
2624    }
2625
2626    #[test]
2627    fn a_query_in_a_script_comment_is_prose() {
2628        let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2629        let found = js_widths(&blank(js, Lang::Js));
2630        assert!(found.is_empty(), "{found:?}");
2631    }
2632
2633    #[test]
2634    fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2635        let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2636        let src = blank(css, Lang::Css);
2637        assert_eq!(media_conditions(&src).len(), 1);
2638    }
2639
2640    #[test]
2641    fn blanking_keeps_byte_offsets_through_multibyte_text() {
2642        // An em dash in a comment and in a string: three bytes each, blanked
2643        // to three spaces, so everything after them sits where it did.
2644        let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2645        let src = blank(css, Lang::Css);
2646        assert_eq!(src.len(), css.len());
2647        let at = css.find("@layer").unwrap();
2648        assert_eq!(&src[at..at + 6], "@layer");
2649        let dropped = drop_layers(css, &["reset"]);
2650        assert!(!dropped.contains("padding"), "{dropped:?}");
2651    }
2652
2653    #[test]
2654    fn a_dropped_layer_keeps_its_important_rules() {
2655        // `!important` inverts layer order, so a rule in a dropped layer marked
2656        // important beats the later makeover layer. Blanking the whole layer
2657        // would hide a real override; the rule stays and the rest is dropped.
2658        let css =
2659            "@layer reset {\n  * { padding: 0; }\n  .card { box-shadow: none !important; }\n}\n";
2660        let dropped = drop_layers(css, &["reset"]);
2661        assert!(!dropped.contains("padding"), "{dropped:?}");
2662        assert!(dropped.contains(".card"), "{dropped:?}");
2663        assert!(dropped.contains("box-shadow"), "{dropped:?}");
2664    }
2665
2666    #[test]
2667    fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2668        let dotted = "@layer reset.base {\n  .x { color: red; }\n}\n";
2669        assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2670        let upper = "@LAYER reset {\n  .x { color: red; }\n}\n";
2671        assert!(!drop_layers(upper, &["reset"]).contains("color"));
2672    }
2673
2674    #[test]
2675    fn blanking_keeps_lines_and_offsets() {
2676        let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2677        let src = blank(css, Lang::Css);
2678        assert_eq!(src.len(), css.len());
2679        assert_eq!(src.lines().count(), css.lines().count());
2680    }
2681
2682    #[test]
2683    fn both_quote_styles_read() {
2684        let want = Density::Touch.media_condition();
2685        for q in ['\'', '"'] {
2686            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2687            let found = touch_density_literals(&src);
2688            assert_eq!(found.len(), 1);
2689            assert_eq!(found[0].1, want);
2690        }
2691    }
2692
2693    #[test]
2694    fn a_file_list_skips_the_sheets_named_generated() {
2695        let dir = frontend("vocab-files");
2696        write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2697        write(&dir, "css/app.css", "body { color: red; }\n");
2698        let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2699        Vocabulary::files(&files)
2700            .generated(&["layout.css"])
2701            .run(&Emit::default());
2702        let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2703            .unwrap_err();
2704        let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2705        assert!(msg.contains("box-shadow"), "got: {msg}");
2706    }
2707}