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