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!(touch_density_literals(&src).is_empty());
1875    }
1876
1877    #[test]
1878    fn whitespace_and_backtick_literals_read() {
1879        let want = Density::Touch.media_condition();
1880        for src in [
1881            format!("const {CONST_NAME}='{want}';\n"),
1882            format!("const {CONST_NAME} = `{want}`;\n"),
1883            format!("const {CONST_NAME} =\n  '{want}';\n"),
1884        ] {
1885            let found = touch_density_literals(&src);
1886            assert_eq!(found.len(), 1, "src: {src:?}");
1887            assert_eq!(found[0].1, want, "src: {src:?}");
1888        }
1889    }
1890
1891    #[test]
1892    fn a_longer_identifier_is_not_the_const() {
1893        // A substring hit on the name must not pass as the declaration.
1894        let want = Density::Touch.media_condition();
1895        for src in [
1896            format!("const OLD_{CONST_NAME} = '{want}';\n"),
1897            format!("const {CONST_NAME}_OLD = '{want}';\n"),
1898        ] {
1899            assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1900        }
1901    }
1902
1903    #[test]
1904    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1905    fn a_commented_out_declaration_is_not_a_declaration() {
1906        // The real const can be deleted and a `//`-prefixed copy left behind;
1907        // the check must not count that as a declaration.
1908        let dir = scratch("commented-decl");
1909        write(
1910            &dir,
1911            "touch.js",
1912            &format!("// {}\n", declaring().trim_end()),
1913        );
1914        check_touch_density(&dir);
1915    }
1916
1917    #[test]
1918    fn a_sniff_named_in_a_comment_is_not_a_use() {
1919        // The word appears, but only in prose about why the check exists.
1920        let dir = scratch("sniff-comment");
1921        write(&dir, "touch.js", &declaring());
1922        write(
1923            &dir,
1924            "note.js",
1925            "// never read ontouchstart or maxTouchPoints here\n",
1926        );
1927        check_touch_density(&dir);
1928    }
1929
1930    #[test]
1931    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1932    fn a_wrong_literal_without_spaces_is_caught() {
1933        // The old reader required ` = ` and skipped this spelling entirely.
1934        let dir = scratch("loose-drift");
1935        write(&dir, "touch.js", &declaring());
1936        write(
1937            &dir,
1938            "haptics.js",
1939            &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1940        );
1941        check_touch_density(&dir);
1942    }
1943
1944    #[test]
1945    fn nested_files_are_read() {
1946        // The server keeps its scripts in subdirectories, and the nested half
1947        // is the half most likely to be a copy.
1948        let dir = scratch("nested");
1949        write(&dir, "touch.js", &declaring());
1950        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1951        let files = js_files(&dir);
1952        assert_eq!(files.len(), 2);
1953    }
1954
1955    #[test]
1956    fn a_non_js_file_is_ignored() {
1957        let dir = scratch("nonjs");
1958        write(&dir, "touch.js", &declaring());
1959        write(&dir, "styles.css", "body { }\n");
1960        assert_eq!(js_files(&dir).len(), 1);
1961    }
1962
1963    fn frontend(name: &str) -> PathBuf {
1964        let dir = scratch(name);
1965        std::fs::create_dir_all(dir.join("css")).unwrap();
1966        std::fs::create_dir_all(dir.join("js")).unwrap();
1967        dir
1968    }
1969
1970    /// A width every size class agrees is a boundary.
1971    fn boundary() -> u16 {
1972        SizeClass::Medium.min_px()
1973    }
1974
1975    #[test]
1976    fn the_crates_own_boundaries_pass() {
1977        let dir = frontend("bp-ok");
1978        write(
1979            &dir,
1980            "css/styles.css",
1981            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1982        );
1983        Breakpoints::in_frontend(&dir).run();
1984    }
1985
1986    #[test]
1987    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1988    fn a_stale_css_width_fails() {
1989        let dir = frontend("bp-css");
1990        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1991        Breakpoints::in_frontend(&dir).run();
1992    }
1993
1994    #[test]
1995    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1996    fn a_stale_js_width_fails() {
1997        let dir = frontend("bp-js");
1998        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1999        Breakpoints::in_frontend(&dir).run();
2000    }
2001
2002    #[test]
2003    fn a_declared_tuning_width_passes() {
2004        let dir = frontend("bp-tuning");
2005        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
2006        Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2007    }
2008
2009    #[test]
2010    fn a_container_query_on_a_shell_boundary_passes() {
2011        // `@container screen (width < 600px)` is a media query in disguise: the
2012        // `screen` container tracks the shell, so its width must be a SizeClass
2013        // boundary. 600 is one.
2014        let dir = frontend("bp-container-ok");
2015        write(
2016            &dir,
2017            "css/styles.css",
2018            &format!("@container screen (width < {}px) {{ }}\n", boundary()),
2019        );
2020        Breakpoints::in_frontend(&dir).run();
2021    }
2022
2023    #[test]
2024    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
2025    fn a_stale_container_width_fails() {
2026        let dir = frontend("bp-container-stale");
2027        write(
2028            &dir,
2029            "css/styles.css",
2030            "@container screen (width < 768px) { }\n",
2031        );
2032        Breakpoints::in_frontend(&dir).run();
2033    }
2034
2035    #[test]
2036    fn a_container_inline_size_condition_is_not_a_width_boundary() {
2037        // An inline-size container query on a component (`tab-strip`) is not a
2038        // shell width and reads no `width` feature, so it is left alone.
2039        let dir = frontend("bp-container-inline");
2040        write(
2041            &dir,
2042            "css/styles.css",
2043            "@container tab-strip (inline-size < 36rem) { }\n",
2044        );
2045        Breakpoints::in_frontend(&dir).run();
2046    }
2047
2048    #[test]
2049    fn a_width_in_a_comment_is_prose() {
2050        // The note explaining which breakpoint used to be here is not a rule,
2051        // and failing a build on documentation would teach people to delete it.
2052        let dir = frontend("bp-comment");
2053        write(
2054            &dir,
2055            "css/styles.css",
2056            "/* was @media (max-width: 768px) until the size classes landed */\n",
2057        );
2058        Breakpoints::in_frontend(&dir).run();
2059    }
2060
2061    #[test]
2062    fn an_unparenthesized_width_is_not_a_breakpoint() {
2063        // A JS string building an inline style states `max-width: 320px` with
2064        // no parentheses. It is a declaration, not a query, and the first
2065        // version of this check failed the build on one.
2066        let dir = frontend("bp-inline");
2067        write(
2068            &dir,
2069            "js/style.js",
2070            "el.style.cssText = 'max-width: 320px; display: block';\n",
2071        );
2072        Breakpoints::in_frontend(&dir).run();
2073    }
2074
2075    #[test]
2076    fn nested_css_is_read() {
2077        // Same argument as the touch check: the nested half is the half most
2078        // likely to be a copy.
2079        let dir = frontend("bp-nested");
2080        write(
2081            &dir,
2082            "css/screens/detail.css",
2083            "@media (max-width: 768px) { }\n",
2084        );
2085        let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
2086        assert!(found.is_err(), "a nested stylesheet must be scanned");
2087    }
2088
2089    #[test]
2090    fn a_css_only_frontend_scans_without_a_js_directory() {
2091        // A CSS-only frontend ships no `js/`, and the recommended `in_frontend`
2092        // form must scan the stylesheets rather than fail on the absent tree.
2093        let dir = scratch("bp-css-only");
2094        write(
2095            &dir,
2096            "css/styles.css",
2097            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
2098        );
2099        Breakpoints::in_frontend(&dir).run();
2100    }
2101
2102    #[test]
2103    fn a_named_list_is_checked() {
2104        let dir = frontend("bp-list");
2105        write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
2106        let listed = dir.join("css/style.css");
2107        let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
2108        let msg = err.downcast_ref::<String>().expect("String payload");
2109        assert!(msg.contains("style.css:1"), "got: {msg}");
2110    }
2111
2112    #[test]
2113    #[should_panic(expected = "read ")]
2114    fn a_listed_file_that_is_gone_fails() {
2115        // The list is hand-maintained, so a path that stopped existing is a
2116        // check quietly covering less than it claims. Louder than skipping it.
2117        let dir = frontend("bp-missing");
2118        Breakpoints::files(&[dir.join("css/never-written.css")]).run();
2119    }
2120
2121    #[test]
2122    fn a_listed_js_file_is_parsed_as_script() {
2123        // The unparenthesized-declaration rule is what separates the two, and
2124        // picking the parser off the extension is the whole difference.
2125        let dir = frontend("bp-list-js");
2126        write(
2127            &dir,
2128            "js/style.js",
2129            "el.style.cssText = 'max-width: 320px';\n",
2130        );
2131        Breakpoints::files(&[dir.join("js/style.js")]).run();
2132    }
2133
2134    #[test]
2135    fn the_error_names_the_file_and_line() {
2136        let dir = frontend("bp-message");
2137        write(
2138            &dir,
2139            "css/styles.css",
2140            "body { }\n@media (max-width: 768px) { }\n",
2141        );
2142        let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
2143        let msg = err
2144            .downcast_ref::<String>()
2145            .expect("panic payload is a String");
2146        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
2147    }
2148
2149    #[test]
2150    fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
2151        // quasi-webview's sheet owns `.run`'s gap the way makeover owns
2152        // `.card`'s shadow, and an app redrawing it wins silently the same way.
2153        let upstream = ".tile-set { gap: 4px; }\n";
2154        let dir = scratch("vocab-upstream");
2155        write(&dir, "css/quasi.css", upstream);
2156        write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
2157        let err = std::panic::catch_unwind(|| {
2158            Vocabulary::in_frontend(&dir)
2159                .upstream(&[upstream])
2160                .run(&Emit::default());
2161        })
2162        .unwrap_err();
2163        let msg = err
2164            .downcast_ref::<String>()
2165            .expect("panic payload is a String");
2166        assert!(
2167            msg.contains("css/styles.css  .tile-set { gap }"),
2168            "got: {msg}"
2169        );
2170        assert!(
2171            !msg.contains("css/quasi.css"),
2172            "the served copy clashed with itself: {msg}"
2173        );
2174
2175        // Without the upstream sheet named, the same tree passes, which is the
2176        // blind spot this closes.
2177        Vocabulary::in_frontend(&dir).run(&Emit::default());
2178    }
2179
2180    #[test]
2181    fn a_rule_restating_a_generated_class_fails_and_names_it() {
2182        let dir = scratch("vocab-clash");
2183        // `.card` is makeover's. An app rule for it beats the generated one,
2184        // because app CSS is unlayered and the generated sheet is not.
2185        write(
2186            &dir,
2187            "css/styles.css",
2188            "body { color: red; }\n.card { box-shadow: none; }\n",
2189        );
2190        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2191            .unwrap_err();
2192        let msg = err
2193            .downcast_ref::<String>()
2194            .expect("panic payload is a String");
2195        assert!(msg.contains(".card"), "got: {msg}");
2196        assert!(msg.contains("box-shadow"), "got: {msg}");
2197        assert!(msg.contains("css/styles.css"), "got: {msg}");
2198    }
2199
2200    #[test]
2201    fn an_app_class_of_its_own_is_left_alone() {
2202        let dir = scratch("vocab-clean");
2203        write(
2204            &dir,
2205            "css/styles.css",
2206            ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
2207        );
2208        Vocabulary::in_frontend(&dir).run(&Emit::default());
2209    }
2210
2211    #[test]
2212    fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
2213        let dir = scratch("vocab-generated");
2214        let opts = Emit::default();
2215        write(&dir, "css/layout.css", &crate::stylesheet(&opts));
2216        // Without the skip this is the loudest failure possible: every class in
2217        // the vocabulary, reported as a clash with the vocabulary.
2218        Vocabulary::in_frontend(&dir)
2219            .generated(&["layout.css"])
2220            .run(&opts);
2221    }
2222
2223    #[test]
2224    fn a_prefixed_app_is_checked_against_its_own_prefix() {
2225        let dir = scratch("vocab-prefix");
2226        let opts = Emit::new().class_prefix("mo-");
2227        // Bare `.card` is the app's own class once the generated sheet writes
2228        // `.mo-card`, so this has to pass.
2229        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
2230        Vocabulary::in_frontend(&dir).run(&opts);
2231
2232        let dir = scratch("vocab-prefix-clash");
2233        write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
2234        assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
2235    }
2236
2237    #[test]
2238    fn a_class_shared_without_a_shared_property_is_left_alone() {
2239        let dir = scratch("vocab-additive");
2240        // The generated `.cell-value` sets only the text colour, so an app
2241        // setting its shape shares the class and takes nothing from it.
2242        write(
2243            &dir,
2244            "css/styles.css",
2245            ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
2246        );
2247        Vocabulary::in_frontend(&dir).run(&Emit::default());
2248    }
2249
2250    #[test]
2251    fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
2252        let dir = scratch("vocab-allowed");
2253        write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
2254        Vocabulary::in_frontend(&dir)
2255            .allow(Allow::class("badge", "padding"))
2256            .run(&Emit::default());
2257
2258        // The same licence against a sheet that no longer collides has to fail,
2259        // or the list only ever grows.
2260        let dir = scratch("vocab-allowed-stale");
2261        write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
2262        let err = std::panic::catch_unwind(|| {
2263            Vocabulary::in_frontend(&dir)
2264                .allow(Allow::class("badge", "padding"))
2265                .run(&Emit::default());
2266        })
2267        .unwrap_err();
2268        let msg = err
2269            .downcast_ref::<String>()
2270            .expect("panic payload is a String");
2271        assert!(msg.contains("no longer collides"), "got: {msg}");
2272    }
2273
2274    #[test]
2275    fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2276        let dir = scratch("vocab-element");
2277        // The defect that shipped for months: no class in the selector, so the
2278        // class pass sees nothing, and every described act in the app takes the
2279        // app's bevel instead of the design system's.
2280        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2281        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2282            .unwrap_err();
2283        let msg = err
2284            .downcast_ref::<String>()
2285            .expect("panic payload is a String");
2286        assert!(msg.contains("select {"), "got: {msg}");
2287        assert!(msg.contains(".field"), "got: {msg}");
2288        assert!(msg.contains("box-shadow"), "got: {msg}");
2289        assert!(msg.contains("css/styles.css"), "got: {msg}");
2290    }
2291
2292    #[test]
2293    fn a_universal_reset_after_the_design_system_is_caught() {
2294        // goingson and MNW both opened with `* { margin: 0; padding: 0 }` in a
2295        // layer after `makeover`, which zeroed every generated padding. Nothing
2296        // said so until a render showed cells with no padding.
2297        let dir = scratch("vocab-universal");
2298        write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2299        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2300            .unwrap_err();
2301        let msg = err
2302            .downcast_ref::<String>()
2303            .expect("panic payload is a String");
2304        assert!(msg.contains("* {"), "got: {msg}");
2305        assert!(msg.contains("padding"), "got: {msg}");
2306    }
2307
2308    #[test]
2309    fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2310        let dir = scratch("vocab-universal-beneath");
2311        write(
2312            &dir,
2313            "css/styles.css",
2314            "/* { */ @layer reset {\n  * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2315        );
2316        Vocabulary::in_frontend(&dir)
2317            .beneath(&["reset"])
2318            .run(&Emit::default());
2319    }
2320
2321    #[test]
2322    fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2323        let dir = scratch("vocab-important-beneath");
2324        // `!important` inverts layer order, so an important declaration in a
2325        // layer beneath makeover beats every later layer. The reduced-motion
2326        // idiom writes exactly this shape, and dropping the layer whole would
2327        // hide the override.
2328        write(
2329            &dir,
2330            "css/styles.css",
2331            "@layer reset {\n  .field { box-shadow: none !important; }\n}\n",
2332        );
2333        let err = std::panic::catch_unwind(|| {
2334            Vocabulary::in_frontend(&dir)
2335                .beneath(&["reset"])
2336                .run(&Emit::default());
2337        })
2338        .unwrap_err();
2339        let msg = err.downcast_ref::<String>().expect("a String payload");
2340        assert!(msg.contains("box-shadow"), "got: {msg}");
2341    }
2342
2343    #[test]
2344    fn a_layer_not_named_beneath_is_still_read() {
2345        let dir = scratch("vocab-universal-other-layer");
2346        write(
2347            &dir,
2348            "css/styles.css",
2349            "@layer base {\n  * { padding: 0; }\n}\n",
2350        );
2351        let err = std::panic::catch_unwind(|| {
2352            Vocabulary::in_frontend(&dir)
2353                .beneath(&["reset"])
2354                .run(&Emit::default());
2355        })
2356        .unwrap_err();
2357        let msg = err.downcast_ref::<String>().expect("a String payload");
2358        assert!(msg.contains("* {"), "got: {msg}");
2359    }
2360
2361    #[test]
2362    fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2363        let dir = scratch("vocab-universal-box");
2364        write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2365        Vocabulary::in_frontend(&dir).run(&Emit::default());
2366    }
2367
2368    #[test]
2369    fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2370        let dir = scratch("vocab-element-handoff");
2371        // What a consumer writes instead of an exception: the element rule
2372        // stays, and a later layer gives the property back on the class. The
2373        // check has to read that as settled or the remedy fails the build it
2374        // was written to fix.
2375        write(
2376            &dir,
2377            "css/styles.css",
2378            "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2379        );
2380        Vocabulary::in_frontend(&dir).run(&Emit::default());
2381    }
2382
2383    #[test]
2384    fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2385        let dir = scratch("vocab-element-spoken-for");
2386        // Within the app's own sheet the class rule outranks the bare element
2387        // rule, so what reaches the design system is `.button`, not `button`.
2388        // The class pass has that pair, here as a reviewed one, and
2389        // reporting it twice would ask for two remedies for one collision.
2390        write(
2391            &dir,
2392            "css/styles.css",
2393            "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2394             .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2395             .field[aria-invalid=\"true\"] {\n    box-shadow: revert-layer;\n}\n",
2396        );
2397        Vocabulary::in_frontend(&dir)
2398            .allow(Allow::class("field", "box-shadow"))
2399            .run(&Emit::default());
2400    }
2401
2402    #[test]
2403    fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2404        let dir = scratch("vocab-element-weak-handoff");
2405        // The shape that reads as fixed and is not: both rules are the app's
2406        // and both are in the same layer, so the state on the element rule
2407        // decides, and the described field keeps the app's sunken fill.
2408        write(
2409            &dir,
2410            "css/styles.css",
2411            "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2412        );
2413        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2414            .unwrap_err();
2415        let msg = err
2416            .downcast_ref::<String>()
2417            .expect("panic payload is a String");
2418        assert!(msg.contains(".field"), "got: {msg}");
2419
2420        // Written to win, it is.
2421        let dir = scratch("vocab-element-strong-handoff");
2422        write(
2423            &dir,
2424            "css/styles.css",
2425            "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2426        );
2427        Vocabulary::in_frontend(&dir).run(&Emit::default());
2428    }
2429
2430    #[test]
2431    fn a_scoped_rule_is_not_read_as_an_element_rule() {
2432        let dir = scratch("vocab-element-scoped");
2433        // It reaches the buttons inside one region rather than every button, so
2434        // whether it lands on a described act depends on where that act
2435        // renders. Failing the build on a guess is the worse error.
2436        write(
2437            &dir,
2438            "css/styles.css",
2439            ".wizard select { box-shadow: none; }\n",
2440        );
2441        Vocabulary::in_frontend(&dir).run(&Emit::default());
2442    }
2443
2444    #[test]
2445    fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2446        let dir = scratch("vocab-element-unpaired");
2447        // `.card` is a container: no generated class sits on a `<footer>`, so
2448        // there is nothing for this rule to take.
2449        write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2450        Vocabulary::in_frontend(&dir).run(&Emit::default());
2451    }
2452
2453    #[test]
2454    fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2455        // goingson's case: a property on `.button` in the app layer beat
2456        // every `.button[data-tone]` arm, and the reviewed pair is what kept
2457        // the class pass quiet about it. The tone paints the ground now, so
2458        // the property that cancels it is `background`.
2459        let dir = scratch("vocab-cancel-tone");
2460        write(
2461            &dir,
2462            "css/styles.css",
2463            ".button { background: var(--surface-raised); }\n",
2464        );
2465        let err = std::panic::catch_unwind(|| {
2466            Vocabulary::in_frontend(&dir)
2467                .allow(Allow::class("button", "background"))
2468                .run(&Emit::default());
2469        })
2470        .unwrap_err();
2471        let msg = err
2472            .downcast_ref::<String>()
2473            .expect("panic payload is a String");
2474        assert!(
2475            msg.contains(".button { background }  cancels"),
2476            "got: {msg}"
2477        );
2478        assert!(
2479            msg.contains(".button[data-tone=\"danger\"] { background }"),
2480            "the message names the variant it cancels: {msg}"
2481        );
2482        assert!(msg.contains("restate"), "and the remedy: {msg}");
2483    }
2484
2485    #[test]
2486    fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2487        // The remedy the message gives, spelled with single quotes and loose
2488        // whitespace to show the arms compare as selectors rather than bytes.
2489        let opts = Emit::default();
2490        let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2491        let mut css = String::from(".button { background: var(--surface-raised); }\n");
2492        for (arm, properties) in &variants["button"] {
2493            if properties.contains("background") {
2494                let loose = arm.replace('"', "'").replace(',', " , ");
2495                let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2496            }
2497        }
2498        let dir = scratch("vocab-cancel-restated");
2499        write(&dir, "css/styles.css", &css);
2500        Vocabulary::in_frontend(&dir)
2501            .allow(Allow::class("button", "background"))
2502            .run(&opts);
2503    }
2504
2505    #[test]
2506    fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2507        // Neither reaches every `.button`, so whether one lands on a toned act
2508        // depends on markup the check does not read.
2509        let dir = scratch("vocab-cancel-scoped");
2510        write(
2511            &dir,
2512            "css/styles.css",
2513            ".toolbar .button { color: var(--content); }\n\
2514             .button.quiet { color: var(--content); }\n",
2515        );
2516        // The class pass still reads both as `.button` declarations; the pair
2517        // is reviewed here so only the layer pass is under test.
2518        Vocabulary::in_frontend(&dir)
2519            .allow(Allow::class("button", "color"))
2520            .run(&Emit::default());
2521    }
2522
2523    #[test]
2524    fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2525        // quasi-webview's SCREEN_CSS, as an app serves it: its own statement
2526        // puts `quasi` under `makeover`, so its `.button { cursor }` loses to
2527        // the disabled arm rather than cancelling it.
2528        let dir = scratch("vocab-cancel-own-order");
2529        write(
2530            &dir,
2531            "css/quasi.css",
2532            "@layer reset, quasi, makeover;\n\
2533             @layer quasi {\n    .button { cursor: pointer; }\n}\n",
2534        );
2535        Vocabulary::in_frontend(&dir)
2536            .allow(Allow::class("button", "cursor"))
2537            .run(&Emit::default());
2538
2539        // The same rule in a layer the statement puts above makeover does.
2540        let dir = scratch("vocab-cancel-own-order-above");
2541        write(
2542            &dir,
2543            "css/app.css",
2544            "@layer reset, makeover, app;\n\
2545             @layer app {\n    .button { cursor: pointer; }\n}\n",
2546        );
2547        let err = std::panic::catch_unwind(|| {
2548            Vocabulary::in_frontend(&dir)
2549                .allow(Allow::class("button", "cursor"))
2550                .run(&Emit::default());
2551        })
2552        .unwrap_err();
2553        let msg = err
2554            .downcast_ref::<String>()
2555            .expect("panic payload is a String");
2556        assert!(msg.contains(".button { cursor }  cancels"), "got: {msg}");
2557    }
2558
2559    #[test]
2560    fn a_handoff_on_the_bare_class_cancels_nothing() {
2561        let dir = scratch("vocab-cancel-handoff");
2562        write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2563        Vocabulary::in_frontend(&dir).run(&Emit::default());
2564    }
2565
2566    #[test]
2567    fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2568        let dir = scratch("vocab-element-allowed");
2569        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2570        Vocabulary::in_frontend(&dir)
2571            .allow(Allow::element("select", "field", "box-shadow"))
2572            .run(&Emit::default());
2573
2574        // And the same licence against a sheet that no longer collides fails,
2575        // for the reason the class list's does.
2576        let dir = scratch("vocab-element-allowed-stale");
2577        write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2578        let err = std::panic::catch_unwind(|| {
2579            Vocabulary::in_frontend(&dir)
2580                .allow(Allow::element("select", "field", "box-shadow"))
2581                .run(&Emit::default());
2582        })
2583        .unwrap_err();
2584        let msg = err
2585            .downcast_ref::<String>()
2586            .expect("panic payload is a String");
2587        assert!(msg.contains("no longer collides"), "got: {msg}");
2588    }
2589
2590    /// How many rung classes the vocabulary holds, and on how many ladders.
2591    fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2592        let mut rungs = 0;
2593        let mut ladders = 0;
2594        for ladder in [
2595            "min-",
2596            "short-",
2597            "stacks-",
2598            "drop-optional-",
2599            "drop-secondary-",
2600        ] {
2601            let here = names
2602                .iter()
2603                .filter(|c| {
2604                    c.strip_prefix(ladder)
2605                        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2606                })
2607                .count();
2608            rungs += here;
2609            ladders += usize::from(here > 0);
2610        }
2611        (rungs, ladders)
2612    }
2613
2614    #[test]
2615    fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2616        let dir = scratch("vocab-seal");
2617        let opts = Emit::default();
2618        let names = crate::vocabulary::names(&opts);
2619        let (rungs, ladders) = ladders(&names);
2620        assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2621        // Markup naming nothing: every class is unused, and each ladder counts
2622        // once rather than once a rung.
2623        let dead = names.len() - rungs + ladders;
2624        write(&dir, "index.html", "<div></div>\n");
2625        let markup = [dir.join("index.html")];
2626
2627        check_vocabulary_use(&markup, &opts, dead);
2628        assert!(
2629            std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2630            "a vocabulary deader than the seal has to fail"
2631        );
2632    }
2633
2634    #[test]
2635    fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2636        // A column carries whichever rung its floor lands on, so a site naming
2637        // one rung is using the ladder, and one naming none has one dead name.
2638        let opts = Emit::default();
2639        let names = crate::vocabulary::names(&opts);
2640        let (rungs, ladders) = ladders(&names);
2641        let dead = names.len() - rungs + ladders;
2642
2643        let dir = scratch("vocab-ladder");
2644        write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2645        let one_rung = [dir.join("index.html")];
2646        // `cell` is named too, so two fewer than a page naming nothing.
2647        check_vocabulary_use(&one_rung, &opts, dead - 2);
2648
2649        // A longer rung does not name a shorter one it starts with.
2650        write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2651        assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2652        assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2653    }
2654
2655    #[test]
2656    fn a_class_is_named_whole_on_both_sides() {
2657        assert!(haystack_names("class=\"row\"", "row"));
2658        assert!(!haystack_names("class=\"row-flex\"", "row"));
2659        assert!(!haystack_names("class=\"flex-row\"", "row"));
2660        assert!(haystack_names("el.classList.add('row')", "row"));
2661    }
2662
2663    #[test]
2664    fn range_syntax_and_units_are_read() {
2665        assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2666        assert_eq!(
2667            media_widths(" (600px <= width < 840px)"),
2668            [Width::Px(600), Width::Px(840)]
2669        );
2670        assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2671        assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2672        assert_eq!(
2673            media_widths(" (max-width: 599.98px)"),
2674            [Width::Other("599.98px".into())]
2675        );
2676        assert_eq!(
2677            media_widths(" (width < 50vw)"),
2678            [Width::Other("50vw".into())]
2679        );
2680        assert!(media_widths(" (min-height: 600px)").is_empty());
2681        assert!(media_widths(" (max-device-width: 600px)").is_empty());
2682        assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
2683    }
2684
2685    #[test]
2686    fn every_size_class_condition_passes_its_own_check() {
2687        let dir = frontend("bp-classes");
2688        let mut css = String::new();
2689        for class in SizeClass::all() {
2690            let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2691        }
2692        write(&dir, "css/styles.css", &css);
2693        write(
2694            &dir,
2695            "js/shell.js",
2696            &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2697        );
2698        Breakpoints::in_frontend(&dir).run();
2699    }
2700
2701    #[test]
2702    #[should_panic(expected = "48em")]
2703    fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2704        let dir = frontend("bp-em");
2705        write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2706        Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2707    }
2708
2709    #[test]
2710    #[should_panic(expected = "(width < 768px)")]
2711    fn a_stale_range_width_fails() {
2712        let dir = frontend("bp-range");
2713        write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2714        Breakpoints::in_frontend(&dir).run();
2715    }
2716
2717    #[test]
2718    #[should_panic(expected = "599px")]
2719    fn the_old_inclusive_compact_bound_is_stale() {
2720        let dir = frontend("bp-599");
2721        write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2722        Breakpoints::in_frontend(&dir).run();
2723    }
2724
2725    #[test]
2726    #[should_panic(expected = "(max-width: 600px)")]
2727    fn an_inclusive_bound_at_a_class_edge_is_stale() {
2728        // The number is a boundary, so a check that reads numbers alone lets
2729        // it through. At exactly 600px it agrees with Compact and the crate
2730        // says Medium.
2731        let dir = frontend("bp-600-inclusive");
2732        write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2733        Breakpoints::in_frontend(&dir).run();
2734    }
2735
2736    #[test]
2737    #[should_panic(expected = "(width <= 600px)")]
2738    fn an_inclusive_range_upper_bound_is_stale() {
2739        let dir = frontend("bp-le-600");
2740        write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2741        Breakpoints::in_frontend(&dir).run();
2742    }
2743
2744    #[test]
2745    fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2746        let dir = frontend("bp-edges-ok");
2747        write(
2748            &dir,
2749            "css/styles.css",
2750            "@media (min-width: 600px) { }\n\
2751             @media (600px <= width) { }\n\
2752             @media (width >= 840px) { }\n",
2753        );
2754        Breakpoints::in_frontend(&dir).run();
2755    }
2756
2757    #[test]
2758    fn a_tuning_max_width_is_left_alone() {
2759        // No class sits on 1400px, so an inclusive bound there has nothing to
2760        // disagree with.
2761        let dir = frontend("bp-tuning-max");
2762        write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2763        Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2764    }
2765
2766    #[test]
2767    fn a_call_in_script_is_not_a_feature() {
2768        assert!(js_widths("const w = Math.max(width, 600);").is_empty());
2769        assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
2770        assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2771    }
2772
2773    #[test]
2774    fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2775        let css = "/*/ @media (max-width: 768px) { } */ a { }";
2776        let stripped = blank(css, Lang::Css);
2777        assert!(!stripped.contains("768"), "{stripped:?}");
2778        assert!(stripped.contains("a { }"));
2779        assert_eq!(stripped.len(), css.len());
2780    }
2781
2782    #[test]
2783    fn a_comment_opener_inside_a_string_is_text() {
2784        // Read as a comment, the `/*` would blank the query after it.
2785        let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2786        let src = blank(css, Lang::Css);
2787        assert_eq!(media_conditions(&src).len(), 1);
2788        assert!(src.contains("768"));
2789
2790        let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2791        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2792    }
2793
2794    #[test]
2795    fn a_regex_literal_does_not_open_a_comment() {
2796        // `/\/*$/` matches trailing slashes; read as a block comment it blanks
2797        // every query after it.
2798        let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2799        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2800    }
2801
2802    #[test]
2803    fn a_division_is_not_a_regex_literal() {
2804        // A `/` after a value divides; it must not swallow the line as regex.
2805        let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2806        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2807    }
2808
2809    #[test]
2810    fn a_css_bad_string_ends_at_a_newline() {
2811        // An unterminated string is a parse error that ends at the newline;
2812        // read as an open string it swallows every rule after it.
2813        let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2814        let src = blank(css, Lang::Css);
2815        assert_eq!(media_conditions(&src).len(), 1);
2816        assert!(src.contains("768"));
2817    }
2818
2819    #[test]
2820    fn an_escaped_quote_does_not_open_a_string() {
2821        let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2822        let src = blank(css, Lang::Css);
2823        assert_eq!(media_conditions(&src).len(), 1);
2824    }
2825
2826    #[test]
2827    fn an_unquoted_url_does_not_open_a_comment() {
2828        let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2829        let src = blank(css, Lang::Css);
2830        assert_eq!(media_conditions(&src).len(), 1);
2831        // A quoted url is already text, and a comment after it still goes.
2832        let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2833                   @media (max-width: 768px) { }\n";
2834        let src = blank(css, Lang::Css);
2835        assert!(!src.contains("gone"), "{src:?}");
2836        assert_eq!(media_conditions(&src).len(), 1);
2837    }
2838
2839    #[test]
2840    fn a_leading_dot_length_is_read() {
2841        // `.5em` is 8px, not the 80px the digits alone would give.
2842        assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2843        assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2844    }
2845
2846    #[test]
2847    fn an_at_media_keyword_reads_in_any_case() {
2848        let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2849        assert_eq!(conditions.len(), 1);
2850        assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2851    }
2852
2853    #[test]
2854    fn a_width_nested_in_a_function_is_read() {
2855        assert_eq!(
2856            media_widths("(max-width: calc(768px))"),
2857            vec![Width::Px(768)]
2858        );
2859        // Arithmetic inside calc is read as its terms; the sum is not a clean
2860        // boundary, so nothing is silently skipped.
2861        assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2862    }
2863
2864    #[test]
2865    fn a_newline_does_not_reset_an_open_group() {
2866        assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2867    }
2868
2869    #[test]
2870    fn custom_media_and_import_carry_media_queries() {
2871        let css = "@custom-media --narrow (max-width: 768px);\n\
2872                   @import url(x.css) (max-width: 768px);\n";
2873        let conditions = media_conditions(css);
2874        assert_eq!(conditions.len(), 2);
2875        for (_, condition) in conditions {
2876            assert!(
2877                media_widths(condition).contains(&Width::Px(768)),
2878                "unread: {condition:?}"
2879            );
2880        }
2881    }
2882
2883    #[test]
2884    fn a_query_in_a_script_comment_is_prose() {
2885        let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2886        assert!(js_widths(&blank(js, Lang::Js)).is_empty());
2887    }
2888
2889    #[test]
2890    fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2891        let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2892        let src = blank(css, Lang::Css);
2893        assert_eq!(media_conditions(&src).len(), 1);
2894    }
2895
2896    #[test]
2897    fn blanking_keeps_byte_offsets_through_multibyte_text() {
2898        // An em dash in a comment and in a string: three bytes each, blanked
2899        // to three spaces, so everything after them sits where it did.
2900        let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2901        let src = blank(css, Lang::Css);
2902        assert_eq!(src.len(), css.len());
2903        let at = css.find("@layer").unwrap();
2904        assert_eq!(&src[at..at + 6], "@layer");
2905        let dropped = drop_layers(css, &["reset"]);
2906        assert!(!dropped.contains("padding"), "{dropped:?}");
2907    }
2908
2909    #[test]
2910    fn a_dropped_layer_keeps_its_important_rules() {
2911        // `!important` inverts layer order, so a rule in a dropped layer marked
2912        // important beats the later makeover layer. Blanking the whole layer
2913        // would hide a real override; the rule stays and the rest is dropped.
2914        let css =
2915            "@layer reset {\n  * { padding: 0; }\n  .card { box-shadow: none !important; }\n}\n";
2916        let dropped = drop_layers(css, &["reset"]);
2917        assert!(!dropped.contains("padding"), "{dropped:?}");
2918        assert!(dropped.contains(".card"), "{dropped:?}");
2919        assert!(dropped.contains("box-shadow"), "{dropped:?}");
2920    }
2921
2922    #[test]
2923    fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2924        let dotted = "@layer reset.base {\n  .x { color: red; }\n}\n";
2925        assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2926        let upper = "@LAYER reset {\n  .x { color: red; }\n}\n";
2927        assert!(!drop_layers(upper, &["reset"]).contains("color"));
2928    }
2929
2930    #[test]
2931    fn blanking_keeps_lines_and_offsets() {
2932        let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2933        let src = blank(css, Lang::Css);
2934        assert_eq!(src.len(), css.len());
2935        assert_eq!(src.lines().count(), css.lines().count());
2936    }
2937
2938    #[test]
2939    fn both_quote_styles_read() {
2940        let want = Density::Touch.media_condition();
2941        for q in ['\'', '"'] {
2942            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2943            let found = touch_density_literals(&src);
2944            assert_eq!(found.len(), 1);
2945            assert_eq!(found[0].1, want);
2946        }
2947    }
2948
2949    #[test]
2950    fn a_file_list_skips_the_sheets_named_generated() {
2951        let dir = frontend("vocab-files");
2952        write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2953        write(&dir, "css/app.css", "body { color: red; }\n");
2954        let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2955        Vocabulary::files(&files)
2956            .generated(&["layout.css"])
2957            .run(&Emit::default());
2958        let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2959            .unwrap_err();
2960        let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2961        assert!(msg.contains("box-shadow"), "got: {msg}");
2962    }
2963}