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