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
697fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
698    haystack
699        .get(from..)?
700        .windows(needle.len())
701        .position(|window| window == needle)
702        .map(|at| at + from)
703}
704
705/// A reviewed exception to [`Vocabulary`]: a collision the app has read and
706/// kept, with a note beside it saying why.
707///
708/// It expires when the collision does. An exception that no longer collides
709/// fails the check, since an unused licence is where the next real collision
710/// lands unnoticed.
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum Allow<'a> {
713    /// A hand-written rule on a generated class takes one of its properties.
714    Class {
715        /// The class, unprefixed as the stylesheet spells it.
716        class: &'a str,
717        /// The property both set.
718        property: &'a str,
719    },
720    /// A bare element rule takes a property the generated sheet sets on a class
721    /// that element carries.
722    Element {
723        /// The element the rule selects.
724        element: &'a str,
725        /// The generated class it reaches.
726        class: &'a str,
727        /// The property both set.
728        property: &'a str,
729    },
730}
731
732impl<'a> Allow<'a> {
733    /// See [`Allow::Class`].
734    pub const fn class(class: &'a str, property: &'a str) -> Self {
735        Self::Class { class, property }
736    }
737
738    /// See [`Allow::Element`].
739    pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
740        Self::Element {
741            element,
742            class,
743            property,
744        }
745    }
746}
747
748/// Fail the build if a hand-written stylesheet takes a property the generated
749/// one already sets, on the same class or on an element that carries it.
750///
751/// The generated sheet sits in `@layer makeover`. App CSS beats it whatever the
752/// specificity, either by being unlayered or by sitting in a layer the app's
753/// order statement puts after `makeover`, so an app declaration for a property
754/// makeover already sets does not merge with it: it wins, silently, and the
755/// design system's version of that component stops applying, and nothing looks.
756///
757/// # Two passes, because a rule can carry no class
758///
759/// The class pass compares an app `.button` against the generated `.button`.
760/// It reads rules by the classes in their selectors, so a rule with no class in
761/// it is invisible to it, and `button { color: var(--content) }` is exactly
762/// that: it sets the property the generated `.button` sets on every described
763/// act, `.button[data-tone="danger"]`'s tone included, which draws a
764/// destructive act like an ordinary one.
765///
766/// The element pass reads that case. `crate::vocabulary::ELEMENT_CLASSES`
767/// says which generated classes an element can carry (CSS cannot say it, and
768/// the renderer can), and a bare element rule taking a property the design
769/// system sets on one of those classes is the same defect as the class case.
770///
771/// Two things are not reported, both deliberately:
772///
773///   - A scoped rule (`.page button`). It reaches the elements inside one
774///     region rather than every one of them, so whether it lands on a described
775///     act depends on where that act renders. The certain case is the one this
776///     reads.
777///   - A property the app names on the class itself, from a rule that outranks
778///     the element rule. Both are the app's and both sit in the same layer, so
779///     that one contest is settled by specificity, and what reaches the design
780///     system is the class rule, which the class pass has already reported
781///     or reviewed. The rank matters: `.field` does not beat
782///     `input[type="text"]`, and a handoff written as the weaker of the two is
783///     a remedy that looks written and is not.
784///
785/// # Why properties and not class names
786///
787/// A shared class name is not by itself a divergence. An app `.badge` that sets
788/// shape while the generated `.badge` sets colour is a correct split, and a
789/// check on names would ask for it to be deleted. On properties, the split is
790/// what the check enforces.
791///
792/// # The exception list
793///
794/// [`Allow::class`] names a `(class, property)` pair this app has reviewed and
795/// kept.
796/// Deciding which are legitimate here would need a selector matcher, and a check
797/// that guesses wrong about specificity fails correct builds, so the app
798/// declares it instead, the same shape as quasi-webview's `RENDERER_OWN`.
799///
800/// A reviewed pairing is a claim about who owns a property, and it expires when
801/// the design system takes the property back.
802///
803/// [`Allow::element`] is the same thing one pass down: an element rule reaching
804/// a generated class that has been read and kept.
805///
806/// The remedy is usually neither. A later layer can hand the property back
807/// with `revert-layer`, which says "whatever the design system set here, keep
808/// it" on the arms makeover actually paints, and that is a statement in the
809/// stylesheet rather than a note in a build script. Handoffs are not reported by
810/// either pass.
811///
812/// A pair that stops colliding fails too. A licence nobody is using is where
813/// the next real collision lands and reads as company.
814///
815/// ```no_run
816/// use makeover_webview::build::drift::{Allow, Vocabulary};
817///
818/// Vocabulary::in_frontend("frontend")
819///     .generated(&["layout.css"])
820///     // Reviewed: the card's shadow is the app's, on the hover arm only.
821///     .allow(Allow::class("card", "box-shadow"))
822///     .run(&makeover_webview::Emit::default());
823/// ```
824#[derive(Debug, Clone)]
825#[must_use = "a check does nothing until it is run"]
826pub struct Vocabulary<'a> {
827    source: Source,
828    generated: Vec<&'a str>,
829    upstream: Vec<&'a str>,
830    allowed: Vec<Allow<'a>>,
831    beneath: Vec<&'a str>,
832}
833
834impl<'a> Vocabulary<'a> {
835    /// Every `.css` under `frontend/css`, recursively.
836    pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
837        Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
838    }
839
840    /// A named list of stylesheets rather than a tree.
841    ///
842    /// For a frontend whose generated and hand-written sheets share a
843    /// directory. Same trade as [`Breakpoints::files`]: the list is
844    /// hand-maintained, and a stylesheet nobody adds to it is unchecked.
845    pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
846        Self::from(Source::files(paths))
847    }
848
849    fn from(source: Source) -> Self {
850        Self {
851            source,
852            generated: Vec::new(),
853            upstream: Vec::new(),
854            allowed: Vec::new(),
855            beneath: Vec::new(),
856        }
857    }
858
859    /// Sheets this crate writes, which are skipped: the generated file setting
860    /// a generated property is the point. Relative to `frontend/css` for
861    /// [`in_frontend`](Self::in_frontend); matched against the end of each path
862    /// for [`files`](Self::files).
863    pub fn generated(mut self, names: &[&'a str]) -> Self {
864        self.generated.extend_from_slice(names);
865        self
866    }
867
868    /// The text of stylesheets that sit between makeover and the app and own
869    /// the classes they style as makeover owns its own.
870    ///
871    /// quasi-webview's `SCREEN_CSS` is the case. This crate cannot name it,
872    /// since quasi builds on this crate and not the other way round, so the app
873    /// that links both hands it in. A hand-written sheet is checked against
874    /// makeover's sheet and every upstream sheet together. A file whose text
875    /// *is* an upstream sheet, the copy an app serves from its frontend, is
876    /// checked against makeover alone and never read as clashing with itself.
877    pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
878        self.upstream.extend_from_slice(sheets);
879        self
880    }
881
882    /// Cascade layers the app orders before `makeover`, by name.
883    ///
884    /// A rule inside one loses to every generated rule whatever its selector,
885    /// so it cannot take a property from the design system and is not read.
886    /// The usual case is a reset: `@layer reset, makeover, ...` with
887    /// `* { margin: 0; padding: 0 }` inside `@layer reset { }` clears browser
888    /// defaults without zeroing the generated spacing. The order itself lives
889    /// in the app's markup, which this check does not read, so naming a layer
890    /// here is the app stating that order.
891    pub fn beneath(mut self, layers: &[&'a str]) -> Self {
892        self.beneath.extend_from_slice(layers);
893        self
894    }
895
896    /// A reviewed exception. See [`Allow`].
897    pub fn allow(mut self, allow: Allow<'a>) -> Self {
898        self.allowed.push(allow);
899        self
900    }
901
902    /// Several reviewed exceptions, for a list the app keeps as a `const`.
903    pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
904        self.allowed.extend(allows);
905        self
906    }
907
908    /// Run the check against the stylesheet `opts` generates. Emits
909    /// `cargo:rerun-if-changed` for every file it read.
910    ///
911    /// # Panics
912    ///
913    /// If a directory or listed file cannot be read, if any hand-written sheet
914    /// takes a generated property without an [`Allow`], or if an [`Allow`] no
915    /// longer collides. A build script has nowhere useful to return an error
916    /// to, and an app quietly overriding its own design system is worse than a
917    /// failed build.
918    pub fn run(&self, opts: &Emit) {
919        let classes: Vec<(&str, &str)> = self
920            .allowed
921            .iter()
922            .filter_map(|allow| match *allow {
923                Allow::Class { class, property } => Some((class, property)),
924                Allow::Element { .. } => None,
925            })
926            .collect();
927        let elements: Vec<(&str, &str, &str)> = self
928            .allowed
929            .iter()
930            .filter_map(|allow| match *allow {
931                Allow::Element {
932                    element,
933                    class,
934                    property,
935                } => Some((element, class, property)),
936                Allow::Class { .. } => None,
937            })
938            .collect();
939        match &self.source {
940            Source::Frontend(frontend) => {
941                let css = frontend.join("css");
942                let files: Vec<PathBuf> = files_with_extension(&css, "css")
943                    .into_iter()
944                    .filter(|p| {
945                        let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
946                        !self.generated.contains(&name.as_str())
947                    })
948                    .collect();
949                check_vocabulary_paths(
950                    &files,
951                    opts,
952                    Some(frontend),
953                    &self.upstream,
954                    &self.beneath,
955                    &classes,
956                    &elements,
957                );
958                // The directory too, so a stylesheet added later reruns it.
959                println!("cargo::rerun-if-changed={}", css.display());
960            }
961            Source::Files(paths) => {
962                let files: Vec<PathBuf> = paths
963                    .iter()
964                    .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
965                    .cloned()
966                    .collect();
967                check_vocabulary_paths(
968                    &files,
969                    opts,
970                    None,
971                    &self.upstream,
972                    &self.beneath,
973                    &classes,
974                    &elements,
975                );
976            }
977        }
978    }
979}
980
981/// The check itself. `root`, when given, is stripped from reported paths.
982fn check_vocabulary_paths(
983    paths: &[PathBuf],
984    opts: &Emit,
985    root: Option<&Path>,
986    upstream: &[&str],
987    beneath: &[&str],
988    allowed: &[(&str, &str)],
989    allowed_elements: &[(&str, &str, &str)],
990) {
991    let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
992    // What a hand-written sheet answers to: makeover's classes and every
993    // upstream sheet's, merged per class.
994    let mut owned = makeover.clone();
995    for sheet in upstream {
996        for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
997            owned.entry(class).or_default().extend(properties);
998        }
999    }
1000    let mut clashes: Vec<String> = Vec::new();
1001    let mut seen: Vec<(String, String)> = Vec::new();
1002    let mut element_clashes: Vec<String> = Vec::new();
1003    let mut element_seen: Vec<(String, String, String)> = Vec::new();
1004
1005    for path in paths {
1006        println!("cargo::rerun-if-changed={}", path.display());
1007        let raw = std::fs::read_to_string(path)
1008            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1009        let name = match root {
1010            Some(root) => display_name(root, path),
1011            None => path.display().to_string(),
1012        };
1013        // An upstream sheet served from the frontend answers to makeover alone:
1014        // measured against itself every declaration in it would clash.
1015        let generated = if upstream.contains(&raw.as_str()) {
1016            &makeover
1017        } else {
1018            &owned
1019        };
1020        // A layer ordered before `makeover` cannot beat it, so its rules are
1021        // not read. See `Vocabulary::beneath`.
1022        let raw = drop_layers(&raw, beneath);
1023        // Read the app's sheet the same way the crate reads its own, or the two
1024        // sides are not comparable.
1025        let local = crate::vocabulary::declarations_by_class(&raw);
1026        for (class, properties) in &local {
1027            let Some(theirs) = generated.get(class) else {
1028                continue;
1029            };
1030            for property in properties.intersection(theirs) {
1031                seen.push((class.clone(), property.clone()));
1032                if allowed.contains(&(class.as_str(), property.as_str())) {
1033                    continue;
1034                }
1035                clashes.push(format!("  {name}  .{class} {{ {property} }}"));
1036            }
1037        }
1038
1039        // The second pass: a rule carrying no class at all, which the first one
1040        // cannot see. What the app says about the class itself settles the
1041        // pair, but only from a rule that outranks the element rule. Both are
1042        // the app's and both are in the same layer, so this one contest is
1043        // decided by specificity. `.field` does not beat `input[type="text"]`.
1044        let mentioned = crate::vocabulary::mentions_by_class(&raw);
1045        let by_element = crate::vocabulary::declarations_by_element(&raw);
1046        for (element, properties) in &by_element {
1047            for class in crate::vocabulary::classes_for_element(element, opts) {
1048                let Some(theirs) = generated.get(&class) else {
1049                    continue;
1050                };
1051                for (property, rank) in properties {
1052                    if !theirs.contains(property) {
1053                        continue;
1054                    }
1055                    // A tie goes to the class rule: at equal specificity the
1056                    // later rule wins, and a remedy is written after the rule
1057                    // it remedies.
1058                    let spoken_for = mentioned
1059                        .get(&class)
1060                        .and_then(|properties| properties.get(property))
1061                        .is_some_and(|theirs| theirs >= rank);
1062                    if spoken_for {
1063                        continue;
1064                    }
1065                    element_seen.push((element.clone(), class.clone(), property.clone()));
1066                    if allowed_elements.contains(&(
1067                        element.as_str(),
1068                        class.as_str(),
1069                        property.as_str(),
1070                    )) {
1071                        continue;
1072                    }
1073                    element_clashes.push(format!(
1074                        "  {name}  {element} {{ {property} }}  beats  .{class} {{ {property} }}"
1075                    ));
1076                }
1077            }
1078        }
1079    }
1080
1081    assert!(
1082        clashes.is_empty(),
1083        "{} hand-written declaration(s) take a property the generated stylesheet, \
1084         or an upstream sheet named to this check, already sets on the same \
1085         class. App CSS wins over @layer makeover, whether by a later layer or \
1086         by being unlayered, so each of these wins over the design system \
1087         silently:\n{}\n\nDelete the declaration, or, if \
1088         it is a deliberate pairing on a different selector arm, add \
1089         (class, property) to this check's allowed list and say why beside it. \
1090         Count the consumers before deciding a divergence is worth keeping.",
1091        clashes.len(),
1092        clashes.join("\n")
1093    );
1094
1095    assert!(
1096        element_clashes.is_empty(),
1097        "{} hand-written element rule(s) take a property the generated \
1098         stylesheet sets on a class that element carries. App CSS wins over \
1099         @layer makeover, whether by a later layer or by being unlayered, so a \
1100         described component rendered on one of these elements loses the \
1101         design system's version of that property silently, which can draw a \
1102         destructive act like an ordinary one:\n{}\n\nHand the \
1103         property back on the arms makeover paints \
1104         (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1105         rule so it stops reaching described markup, or add \
1106         (element, class, property) to this check's allowed-elements list and \
1107         say why beside it.",
1108        element_clashes.len(),
1109        element_clashes.join("\n")
1110    );
1111
1112    let stale: Vec<&(&str, &str)> = allowed
1113        .iter()
1114        .filter(|(class, property)| {
1115            !seen.contains(&((*class).to_string(), (*property).to_string()))
1116        })
1117        .collect();
1118    assert!(
1119        stale.is_empty(),
1120        "the allowed list declares {stale:?}, which no longer collides with \
1121         anything. Delete the entries: an exception nobody is using is where the \
1122         next real collision lands and reads as company."
1123    );
1124
1125    let stale: Vec<&(&str, &str, &str)> = allowed_elements
1126        .iter()
1127        .filter(|(element, class, property)| {
1128            !element_seen.contains(&(
1129                (*element).to_string(),
1130                (*class).to_string(),
1131                (*property).to_string(),
1132            ))
1133        })
1134        .collect();
1135    assert!(
1136        stale.is_empty(),
1137        "the allowed-elements list declares {stale:?}, which no longer collides \
1138         with anything. Delete the entries: an exception nobody is using is \
1139         where the next real collision lands and reads as company."
1140    );
1141}
1142
1143/// Whether `haystack` names `class` whole, rather than as part of a longer
1144/// name: `min-1` must not be read as used because `min-16` is, and `row` must
1145/// not be read as used because `row-flex` or `flex-row` is.
1146fn haystack_names(haystack: &str, class: &str) -> bool {
1147    let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
1148    haystack.match_indices(class).any(|(at, found)| {
1149        haystack[..at].chars().next_back().is_none_or(|c| !part(c))
1150            && haystack[at + found.len()..]
1151                .chars()
1152                .next()
1153                .is_none_or(|c| !part(c))
1154    })
1155}
1156
1157/// Warn when the generated vocabulary has grown dead, and fail when it grows
1158/// deader than the recorded high-water mark.
1159///
1160/// A generated class no markup emits is a rule shipped to every user for
1161/// nothing. Some dead vocabulary is normal, so the count alone is not a
1162/// failure; what this checks is the direction. Recording the count in the
1163/// build script makes a change that worsens it fail rather than go unnoticed.
1164///
1165/// One-sided, the same shape as the MNW server's `frontend_globals` seal:
1166/// exceeding `high_water` fails, coming in under it warns and asks for the seal
1167/// to be lowered. A build that fails because dead CSS was deleted would teach
1168/// the wrong lesson.
1169///
1170/// `markup` is every file that can carry a class: templates, `.js`, `.html`,
1171/// and any Rust that writes markup. A class is counted as used if its name
1172/// appears whole in any of them, which is deliberately generous. A stricter reading
1173/// would need to know how each app builds its class strings, and a check that
1174/// guesses wrong fails a correct build.
1175///
1176/// # Panics
1177///
1178/// If a listed file cannot be read, or if more classes are unused than
1179/// `high_water`.
1180pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1181    let generated = crate::vocabulary::names(opts);
1182    let mut haystack = String::new();
1183    for path in markup {
1184        let path = path.as_ref();
1185        println!("cargo::rerun-if-changed={}", path.display());
1186        haystack.push_str(
1187            &std::fs::read_to_string(path)
1188                .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
1189        );
1190        haystack.push('\n');
1191    }
1192
1193    // A ladder is one piece of vocabulary, not one per rung. A column carries
1194    // whichever `min-N` rung its floor lands on and a table whichever `short-N`
1195    // rung its essentials need, so no site names every rung and counting each
1196    // would seal the dead count at the ladder's length. A ladder is used if any
1197    // rung is, and counted once if none is.
1198    const LADDERS: &[&str] = &["min-", "short-"];
1199    let rung_of = |class: &str| -> Option<&'static str> {
1200        let name = class.strip_prefix(opts.class_prefix)?;
1201        LADDERS.iter().copied().find(|ladder| {
1202            name.strip_prefix(ladder)
1203                .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1204        })
1205    };
1206    let mut unused: Vec<String> = generated
1207        .iter()
1208        .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1209        .cloned()
1210        .collect();
1211    for ladder in LADDERS {
1212        let rungs: Vec<&String> = generated
1213            .iter()
1214            .filter(|class| rung_of(class) == Some(ladder))
1215            .collect();
1216        if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1217            unused.push(format!("{}{ladder}N", opts.class_prefix));
1218        }
1219    }
1220
1221    assert!(
1222        unused.len() <= high_water,
1223        "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1224         The vocabulary grew or the markup stopped using it:\n{}",
1225        unused.len(),
1226        generated.len(),
1227        high_water,
1228        unused
1229            .iter()
1230            .map(|c| format!("  .{c}"))
1231            .collect::<Vec<_>>()
1232            .join("\n")
1233    );
1234
1235    if unused.len() < high_water {
1236        println!(
1237            "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1238             lower the seal so it cannot grow back",
1239            unused.len(),
1240            high_water
1241        );
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use std::fmt::Write as _;
1249
1250    fn scratch(name: &str) -> PathBuf {
1251        let dir =
1252            std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1253        let _ = std::fs::remove_dir_all(&dir);
1254        std::fs::create_dir_all(&dir).expect("create scratch");
1255        dir
1256    }
1257
1258    fn write(dir: &Path, name: &str, src: &str) {
1259        if let Some(parent) = dir.join(name).parent() {
1260            std::fs::create_dir_all(parent).unwrap();
1261        }
1262        std::fs::write(dir.join(name), src).unwrap();
1263    }
1264
1265    fn declaring() -> String {
1266        format!(
1267            "const {CONST_NAME} = '{}';\n",
1268            Density::Touch.media_condition()
1269        )
1270    }
1271
1272    #[test]
1273    fn the_crates_own_string_passes() {
1274        let dir = scratch("ok");
1275        write(&dir, "touch.js", &declaring());
1276        check_touch_density(&dir);
1277    }
1278
1279    #[test]
1280    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1281    fn a_drifted_literal_fails() {
1282        let dir = scratch("drift");
1283        write(&dir, "touch.js", &declaring());
1284        write(
1285            &dir,
1286            "haptics.js",
1287            &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1288        );
1289        check_touch_density(&dir);
1290    }
1291
1292    #[test]
1293    #[should_panic(expected = "device sniff")]
1294    fn the_sniff_cannot_come_back() {
1295        let dir = scratch("sniff");
1296        write(&dir, "touch.js", &declaring());
1297        write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1298        check_touch_density(&dir);
1299    }
1300
1301    #[test]
1302    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1303    fn a_frontend_that_states_nothing_fails() {
1304        let dir = scratch("empty");
1305        write(&dir, "app.js", "export const x = 1;\n");
1306        check_touch_density(&dir);
1307    }
1308
1309    #[test]
1310    fn a_use_site_is_not_a_declaration() {
1311        // The const is read far more often than it is declared, and a read
1312        // states no string. Counting one as a declaration would make the
1313        // `found > 0` assertion pass on a frontend that only imports it.
1314        let src =
1315            format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1316        assert!(touch_density_literals(&src).is_empty());
1317    }
1318
1319    #[test]
1320    fn nested_files_are_read() {
1321        // The server keeps its scripts in subdirectories, and the nested half
1322        // is the half most likely to be a copy.
1323        let dir = scratch("nested");
1324        write(&dir, "touch.js", &declaring());
1325        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1326        let files = js_files(&dir);
1327        assert_eq!(files.len(), 2);
1328    }
1329
1330    #[test]
1331    fn a_non_js_file_is_ignored() {
1332        let dir = scratch("nonjs");
1333        write(&dir, "touch.js", &declaring());
1334        write(&dir, "styles.css", "body { }\n");
1335        assert_eq!(js_files(&dir).len(), 1);
1336    }
1337
1338    fn frontend(name: &str) -> PathBuf {
1339        let dir = scratch(name);
1340        std::fs::create_dir_all(dir.join("css")).unwrap();
1341        std::fs::create_dir_all(dir.join("js")).unwrap();
1342        dir
1343    }
1344
1345    /// A width every size class agrees is a boundary.
1346    fn boundary() -> u16 {
1347        SizeClass::Medium.min_px()
1348    }
1349
1350    #[test]
1351    fn the_crates_own_boundaries_pass() {
1352        let dir = frontend("bp-ok");
1353        write(
1354            &dir,
1355            "css/styles.css",
1356            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1357        );
1358        Breakpoints::in_frontend(&dir).run();
1359    }
1360
1361    #[test]
1362    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1363    fn a_stale_css_width_fails() {
1364        let dir = frontend("bp-css");
1365        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1366        Breakpoints::in_frontend(&dir).run();
1367    }
1368
1369    #[test]
1370    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1371    fn a_stale_js_width_fails() {
1372        let dir = frontend("bp-js");
1373        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1374        Breakpoints::in_frontend(&dir).run();
1375    }
1376
1377    #[test]
1378    fn a_declared_tuning_width_passes() {
1379        let dir = frontend("bp-tuning");
1380        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1381        Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1382    }
1383
1384    #[test]
1385    fn a_width_in_a_comment_is_prose() {
1386        // The note explaining which breakpoint used to be here is not a rule,
1387        // and failing a build on documentation would teach people to delete it.
1388        let dir = frontend("bp-comment");
1389        write(
1390            &dir,
1391            "css/styles.css",
1392            "/* was @media (max-width: 768px) until the size classes landed */\n",
1393        );
1394        Breakpoints::in_frontend(&dir).run();
1395    }
1396
1397    #[test]
1398    fn an_unparenthesized_width_is_not_a_breakpoint() {
1399        // A JS string building an inline style states `max-width: 320px` with
1400        // no parentheses. It is a declaration, not a query, and the first
1401        // version of this check failed the build on one.
1402        let dir = frontend("bp-inline");
1403        write(
1404            &dir,
1405            "js/style.js",
1406            "el.style.cssText = 'max-width: 320px; display: block';\n",
1407        );
1408        Breakpoints::in_frontend(&dir).run();
1409    }
1410
1411    #[test]
1412    fn nested_css_is_read() {
1413        // Same argument as the touch check: the nested half is the half most
1414        // likely to be a copy.
1415        let dir = frontend("bp-nested");
1416        write(
1417            &dir,
1418            "css/screens/detail.css",
1419            "@media (max-width: 768px) { }\n",
1420        );
1421        let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1422        assert!(found.is_err(), "a nested stylesheet must be scanned");
1423    }
1424
1425    #[test]
1426    fn a_named_list_is_checked() {
1427        let dir = frontend("bp-list");
1428        write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1429        let listed = dir.join("css/style.css");
1430        let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1431        let msg = err.downcast_ref::<String>().expect("String payload");
1432        assert!(msg.contains("style.css:1"), "got: {msg}");
1433    }
1434
1435    #[test]
1436    #[should_panic(expected = "read ")]
1437    fn a_listed_file_that_is_gone_fails() {
1438        // The list is hand-maintained, so a path that stopped existing is a
1439        // check quietly covering less than it claims. Louder than skipping it.
1440        let dir = frontend("bp-missing");
1441        Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1442    }
1443
1444    #[test]
1445    fn a_listed_js_file_is_parsed_as_script() {
1446        // The unparenthesized-declaration rule is what separates the two, and
1447        // picking the parser off the extension is the whole difference.
1448        let dir = frontend("bp-list-js");
1449        write(
1450            &dir,
1451            "js/style.js",
1452            "el.style.cssText = 'max-width: 320px';\n",
1453        );
1454        Breakpoints::files(&[dir.join("js/style.js")]).run();
1455    }
1456
1457    #[test]
1458    fn the_error_names_the_file_and_line() {
1459        let dir = frontend("bp-message");
1460        write(
1461            &dir,
1462            "css/styles.css",
1463            "body { }\n@media (max-width: 768px) { }\n",
1464        );
1465        let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1466        let msg = err
1467            .downcast_ref::<String>()
1468            .expect("panic payload is a String");
1469        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1470    }
1471
1472    #[test]
1473    fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1474        // quasi-webview's sheet owns `.run`'s gap the way makeover owns
1475        // `.card`'s shadow, and an app redrawing it wins silently the same way.
1476        let upstream = ".tile-set { gap: 4px; }\n";
1477        let dir = scratch("vocab-upstream");
1478        write(&dir, "css/quasi.css", upstream);
1479        write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1480        let err = std::panic::catch_unwind(|| {
1481            Vocabulary::in_frontend(&dir)
1482                .upstream(&[upstream])
1483                .run(&Emit::default());
1484        })
1485        .unwrap_err();
1486        let msg = err
1487            .downcast_ref::<String>()
1488            .expect("panic payload is a String");
1489        assert!(
1490            msg.contains("css/styles.css  .tile-set { gap }"),
1491            "got: {msg}"
1492        );
1493        assert!(
1494            !msg.contains("css/quasi.css"),
1495            "the served copy clashed with itself: {msg}"
1496        );
1497
1498        // Without the upstream sheet named, the same tree passes, which is the
1499        // blind spot this closes.
1500        Vocabulary::in_frontend(&dir).run(&Emit::default());
1501    }
1502
1503    #[test]
1504    fn a_rule_restating_a_generated_class_fails_and_names_it() {
1505        let dir = scratch("vocab-clash");
1506        // `.card` is makeover's. An app rule for it beats the generated one,
1507        // because app CSS is unlayered and the generated sheet is not.
1508        write(
1509            &dir,
1510            "css/styles.css",
1511            "body { color: red; }\n.card { box-shadow: none; }\n",
1512        );
1513        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1514            .unwrap_err();
1515        let msg = err
1516            .downcast_ref::<String>()
1517            .expect("panic payload is a String");
1518        assert!(msg.contains(".card"), "got: {msg}");
1519        assert!(msg.contains("box-shadow"), "got: {msg}");
1520        assert!(msg.contains("css/styles.css"), "got: {msg}");
1521    }
1522
1523    #[test]
1524    fn an_app_class_of_its_own_is_left_alone() {
1525        let dir = scratch("vocab-clean");
1526        write(
1527            &dir,
1528            "css/styles.css",
1529            ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1530        );
1531        Vocabulary::in_frontend(&dir).run(&Emit::default());
1532    }
1533
1534    #[test]
1535    fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1536        let dir = scratch("vocab-generated");
1537        let opts = Emit::default();
1538        write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1539        // Without the skip this is the loudest failure possible: every class in
1540        // the vocabulary, reported as a clash with the vocabulary.
1541        Vocabulary::in_frontend(&dir)
1542            .generated(&["layout.css"])
1543            .run(&opts);
1544    }
1545
1546    #[test]
1547    fn a_prefixed_app_is_checked_against_its_own_prefix() {
1548        let dir = scratch("vocab-prefix");
1549        let opts = Emit::new().class_prefix("mo-");
1550        // Bare `.card` is the app's own class once the generated sheet writes
1551        // `.mo-card`, so this has to pass.
1552        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1553        Vocabulary::in_frontend(&dir).run(&opts);
1554
1555        let dir = scratch("vocab-prefix-clash");
1556        write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1557        assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1558    }
1559
1560    #[test]
1561    fn a_class_shared_without_a_shared_property_is_left_alone() {
1562        let dir = scratch("vocab-additive");
1563        // The generated `.cell-value` sets only the text colour, so an app
1564        // setting its shape shares the class and takes nothing from it.
1565        write(
1566            &dir,
1567            "css/styles.css",
1568            ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1569        );
1570        Vocabulary::in_frontend(&dir).run(&Emit::default());
1571    }
1572
1573    #[test]
1574    fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1575        let dir = scratch("vocab-allowed");
1576        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1577        Vocabulary::in_frontend(&dir)
1578            .allow(Allow::class("card", "box-shadow"))
1579            .run(&Emit::default());
1580
1581        // The same licence against a sheet that no longer collides has to fail,
1582        // or the list only ever grows.
1583        let dir = scratch("vocab-allowed-stale");
1584        write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1585        let err = std::panic::catch_unwind(|| {
1586            Vocabulary::in_frontend(&dir)
1587                .allow(Allow::class("card", "box-shadow"))
1588                .run(&Emit::default());
1589        })
1590        .unwrap_err();
1591        let msg = err
1592            .downcast_ref::<String>()
1593            .expect("panic payload is a String");
1594        assert!(msg.contains("no longer collides"), "got: {msg}");
1595    }
1596
1597    #[test]
1598    fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1599        let dir = scratch("vocab-element");
1600        // The defect that shipped for months: no class in the selector, so the
1601        // class pass sees nothing, and every described act in the app takes the
1602        // app's bevel instead of the design system's.
1603        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1604        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1605            .unwrap_err();
1606        let msg = err
1607            .downcast_ref::<String>()
1608            .expect("panic payload is a String");
1609        assert!(msg.contains("select {"), "got: {msg}");
1610        assert!(msg.contains(".field"), "got: {msg}");
1611        assert!(msg.contains("box-shadow"), "got: {msg}");
1612        assert!(msg.contains("css/styles.css"), "got: {msg}");
1613    }
1614
1615    #[test]
1616    fn a_universal_reset_after_the_design_system_is_caught() {
1617        // goingson and MNW both opened with `* { margin: 0; padding: 0 }` in a
1618        // layer after `makeover`, which zeroed every generated padding. Nothing
1619        // said so until a render showed cells with no padding.
1620        let dir = scratch("vocab-universal");
1621        write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
1622        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1623            .unwrap_err();
1624        let msg = err
1625            .downcast_ref::<String>()
1626            .expect("panic payload is a String");
1627        assert!(msg.contains("* {"), "got: {msg}");
1628        assert!(msg.contains("padding"), "got: {msg}");
1629    }
1630
1631    #[test]
1632    fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
1633        let dir = scratch("vocab-universal-beneath");
1634        write(
1635            &dir,
1636            "css/styles.css",
1637            "/* { */ @layer reset {\n  * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
1638        );
1639        Vocabulary::in_frontend(&dir)
1640            .beneath(&["reset"])
1641            .run(&Emit::default());
1642    }
1643
1644    #[test]
1645    fn a_layer_not_named_beneath_is_still_read() {
1646        let dir = scratch("vocab-universal-other-layer");
1647        write(
1648            &dir,
1649            "css/styles.css",
1650            "@layer base {\n  * { padding: 0; }\n}\n",
1651        );
1652        let err = std::panic::catch_unwind(|| {
1653            Vocabulary::in_frontend(&dir)
1654                .beneath(&["reset"])
1655                .run(&Emit::default());
1656        })
1657        .unwrap_err();
1658        let msg = err.downcast_ref::<String>().expect("a String payload");
1659        assert!(msg.contains("* {"), "got: {msg}");
1660    }
1661
1662    #[test]
1663    fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
1664        let dir = scratch("vocab-universal-box");
1665        write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
1666        Vocabulary::in_frontend(&dir).run(&Emit::default());
1667    }
1668
1669    #[test]
1670    fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1671        let dir = scratch("vocab-element-handoff");
1672        // What a consumer writes instead of an exception: the element rule
1673        // stays, and a later layer gives the property back on the class. The
1674        // check has to read that as settled or the remedy fails the build it
1675        // was written to fix.
1676        write(
1677            &dir,
1678            "css/styles.css",
1679            "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1680        );
1681        Vocabulary::in_frontend(&dir).run(&Emit::default());
1682    }
1683
1684    #[test]
1685    fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1686        let dir = scratch("vocab-element-spoken-for");
1687        // Within the app's own sheet the class rule outranks the bare element
1688        // rule, so what reaches the design system is `.button`, not `button`.
1689        // The class pass has that pair, here as a reviewed one, and
1690        // reporting it twice would ask for two remedies for one collision.
1691        write(
1692            &dir,
1693            "css/styles.css",
1694            "select { box-shadow: none; }\n.field { box-shadow: none; }\n",
1695        );
1696        Vocabulary::in_frontend(&dir)
1697            .allow(Allow::class("field", "box-shadow"))
1698            .run(&Emit::default());
1699    }
1700
1701    #[test]
1702    fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1703        let dir = scratch("vocab-element-weak-handoff");
1704        // The shape that reads as fixed and is not: both rules are the app's
1705        // and both are in the same layer, so the state on the element rule
1706        // decides, and the described field keeps the app's sunken fill.
1707        write(
1708            &dir,
1709            "css/styles.css",
1710            "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1711        );
1712        let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1713            .unwrap_err();
1714        let msg = err
1715            .downcast_ref::<String>()
1716            .expect("panic payload is a String");
1717        assert!(msg.contains(".field"), "got: {msg}");
1718
1719        // Written to win, it is.
1720        let dir = scratch("vocab-element-strong-handoff");
1721        write(
1722            &dir,
1723            "css/styles.css",
1724            "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1725        );
1726        Vocabulary::in_frontend(&dir).run(&Emit::default());
1727    }
1728
1729    #[test]
1730    fn a_scoped_rule_is_not_read_as_an_element_rule() {
1731        let dir = scratch("vocab-element-scoped");
1732        // It reaches the buttons inside one region rather than every button, so
1733        // whether it lands on a described act depends on where that act
1734        // renders. Failing the build on a guess is the worse error.
1735        write(
1736            &dir,
1737            "css/styles.css",
1738            ".wizard select { box-shadow: none; }\n",
1739        );
1740        Vocabulary::in_frontend(&dir).run(&Emit::default());
1741    }
1742
1743    #[test]
1744    fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1745        let dir = scratch("vocab-element-unpaired");
1746        // `.card` is a container: no generated class sits on a `<footer>`, so
1747        // there is nothing for this rule to take.
1748        write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1749        Vocabulary::in_frontend(&dir).run(&Emit::default());
1750    }
1751
1752    #[test]
1753    fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1754        let dir = scratch("vocab-element-allowed");
1755        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1756        Vocabulary::in_frontend(&dir)
1757            .allow(Allow::element("select", "field", "box-shadow"))
1758            .run(&Emit::default());
1759
1760        // And the same licence against a sheet that no longer collides fails,
1761        // for the reason the class list's does.
1762        let dir = scratch("vocab-element-allowed-stale");
1763        write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1764        let err = std::panic::catch_unwind(|| {
1765            Vocabulary::in_frontend(&dir)
1766                .allow(Allow::element("select", "field", "box-shadow"))
1767                .run(&Emit::default());
1768        })
1769        .unwrap_err();
1770        let msg = err
1771            .downcast_ref::<String>()
1772            .expect("panic payload is a String");
1773        assert!(msg.contains("no longer collides"), "got: {msg}");
1774    }
1775
1776    /// How many rung classes the vocabulary holds, and on how many ladders.
1777    fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
1778        let mut rungs = 0;
1779        let mut ladders = 0;
1780        for ladder in ["min-", "short-"] {
1781            let here = names
1782                .iter()
1783                .filter(|c| {
1784                    c.strip_prefix(ladder)
1785                        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1786                })
1787                .count();
1788            rungs += here;
1789            ladders += usize::from(here > 0);
1790        }
1791        (rungs, ladders)
1792    }
1793
1794    #[test]
1795    fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
1796        let dir = scratch("vocab-seal");
1797        let opts = Emit::default();
1798        let names = crate::vocabulary::names(&opts);
1799        let (rungs, ladders) = ladders(&names);
1800        assert!(ladders >= 1, "the floor ladder is in the vocabulary");
1801        // Markup naming nothing: every class is unused, and each ladder counts
1802        // once rather than once a rung.
1803        let dead = names.len() - rungs + ladders;
1804        write(&dir, "index.html", "<div></div>\n");
1805        let markup = [dir.join("index.html")];
1806
1807        check_vocabulary_use(&markup, &opts, dead);
1808        assert!(
1809            std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
1810            "a vocabulary deader than the seal has to fail"
1811        );
1812    }
1813
1814    #[test]
1815    fn one_rung_of_the_floor_ladder_uses_the_ladder() {
1816        // A column carries whichever rung its floor lands on, so a site naming
1817        // one rung is using the ladder, and one naming none has one dead name.
1818        let opts = Emit::default();
1819        let names = crate::vocabulary::names(&opts);
1820        let (rungs, ladders) = ladders(&names);
1821        let dead = names.len() - rungs + ladders;
1822
1823        let dir = scratch("vocab-ladder");
1824        write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
1825        let one_rung = [dir.join("index.html")];
1826        // `cell` is named too, so two fewer than a page naming nothing.
1827        check_vocabulary_use(&one_rung, &opts, dead - 2);
1828
1829        // A longer rung does not name a shorter one it starts with.
1830        write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
1831        assert!(haystack_names("<div class=\"min-24\">", "min-24"));
1832        assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
1833    }
1834
1835    #[test]
1836    fn a_class_is_named_whole_on_both_sides() {
1837        assert!(haystack_names("class=\"row\"", "row"));
1838        assert!(!haystack_names("class=\"row-flex\"", "row"));
1839        assert!(!haystack_names("class=\"flex-row\"", "row"));
1840        assert!(haystack_names("el.classList.add('row')", "row"));
1841    }
1842
1843    #[test]
1844    fn range_syntax_and_units_are_read() {
1845        assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
1846        assert_eq!(
1847            media_widths(" (600px <= width < 840px)"),
1848            [Width::Px(600), Width::Px(840)]
1849        );
1850        assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
1851        assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
1852        assert_eq!(
1853            media_widths(" (max-width: 599.98px)"),
1854            [Width::Other("599.98px".into())]
1855        );
1856        assert_eq!(
1857            media_widths(" (width < 50vw)"),
1858            [Width::Other("50vw".into())]
1859        );
1860        assert!(media_widths(" (min-height: 600px)").is_empty());
1861        assert!(media_widths(" (max-device-width: 600px)").is_empty());
1862        assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
1863    }
1864
1865    #[test]
1866    fn every_size_class_condition_passes_its_own_check() {
1867        let dir = frontend("bp-classes");
1868        let mut css = String::new();
1869        for class in SizeClass::all() {
1870            let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
1871        }
1872        write(&dir, "css/styles.css", &css);
1873        write(
1874            &dir,
1875            "js/shell.js",
1876            &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
1877        );
1878        Breakpoints::in_frontend(&dir).run();
1879    }
1880
1881    #[test]
1882    #[should_panic(expected = "48em")]
1883    fn a_width_in_em_is_a_width_and_not_a_bare_number() {
1884        let dir = frontend("bp-em");
1885        write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
1886        Breakpoints::in_frontend(&dir).tuning(&[48]).run();
1887    }
1888
1889    #[test]
1890    #[should_panic(expected = "(width < 768px)")]
1891    fn a_stale_range_width_fails() {
1892        let dir = frontend("bp-range");
1893        write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
1894        Breakpoints::in_frontend(&dir).run();
1895    }
1896
1897    #[test]
1898    #[should_panic(expected = "599px")]
1899    fn the_old_inclusive_compact_bound_is_stale() {
1900        let dir = frontend("bp-599");
1901        write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
1902        Breakpoints::in_frontend(&dir).run();
1903    }
1904
1905    #[test]
1906    fn a_call_in_script_is_not_a_feature() {
1907        assert!(js_widths("const w = Math.max(width, 600);").is_empty());
1908        assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
1909        assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
1910    }
1911
1912    #[test]
1913    fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
1914        let css = "/*/ @media (max-width: 768px) { } */ a { }";
1915        let stripped = blank(css, Lang::Css);
1916        assert!(!stripped.contains("768"), "{stripped:?}");
1917        assert!(stripped.contains("a { }"));
1918        assert_eq!(stripped.len(), css.len());
1919    }
1920
1921    #[test]
1922    fn a_comment_opener_inside_a_string_is_text() {
1923        // Read as a comment, the `/*` would blank the query after it.
1924        let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
1925        let src = blank(css, Lang::Css);
1926        assert_eq!(media_conditions(&src).len(), 1);
1927        assert!(src.contains("768"));
1928
1929        let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
1930        assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
1931    }
1932
1933    #[test]
1934    fn a_query_in_a_script_comment_is_prose() {
1935        let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
1936        assert!(js_widths(&blank(js, Lang::Js)).is_empty());
1937    }
1938
1939    #[test]
1940    fn a_brace_inside_a_css_string_does_not_end_a_condition() {
1941        let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
1942        let src = blank(css, Lang::Css);
1943        assert_eq!(media_conditions(&src).len(), 1);
1944    }
1945
1946    #[test]
1947    fn blanking_keeps_byte_offsets_through_multibyte_text() {
1948        // An em dash in a comment and in a string: three bytes each, blanked
1949        // to three spaces, so everything after them sits where it did.
1950        let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
1951        let src = blank(css, Lang::Css);
1952        assert_eq!(src.len(), css.len());
1953        let at = css.find("@layer").unwrap();
1954        assert_eq!(&src[at..at + 6], "@layer");
1955        let dropped = drop_layers(css, &["reset"]);
1956        assert!(!dropped.contains("padding"), "{dropped:?}");
1957    }
1958
1959    #[test]
1960    fn blanking_keeps_lines_and_offsets() {
1961        let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
1962        let src = blank(css, Lang::Css);
1963        assert_eq!(src.len(), css.len());
1964        assert_eq!(src.lines().count(), css.lines().count());
1965    }
1966
1967    #[test]
1968    fn both_quote_styles_read() {
1969        let want = Density::Touch.media_condition();
1970        for q in ['\'', '"'] {
1971            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
1972            let found = touch_density_literals(&src);
1973            assert_eq!(found.len(), 1);
1974            assert_eq!(found[0].1, want);
1975        }
1976    }
1977
1978    #[test]
1979    fn a_file_list_skips_the_sheets_named_generated() {
1980        let dir = frontend("vocab-files");
1981        write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
1982        write(&dir, "css/app.css", "body { color: red; }\n");
1983        let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
1984        Vocabulary::files(&files)
1985            .generated(&["layout.css"])
1986            .run(&Emit::default());
1987        let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
1988            .unwrap_err();
1989        let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
1990        assert!(msg.contains("box-shadow"), "got: {msg}");
1991    }
1992}