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