Skip to main content

makeover_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 is not an 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 makeover_geometry::{Density, SizeClass};
17
18/// The declaration this check reads. Shared vocabulary, not a parameter: two
19/// apps and a server naming the same string want the same name for it.
20const CONST_NAME: &str = "TOUCH_DENSITY";
21
22/// The capability sniffs the media query replaced, so neither can come back by
23/// copy-paste.
24///
25/// Both ask the hardware what it has rather than what is pointing at the
26/// screen, so both say yes to a touchscreen laptop driving a mouse.
27const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
28
29/// Fail the build if a JS copy of the touch-density query has drifted from
30/// [`Density::Touch`].
31///
32/// Every `.js` file under `js_dir`, recursively, must state the crate's own
33/// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must
34/// declare it, and no file may name a capability sniff.
35///
36/// The string is the crate's and no app gets a say in it, which is why this
37/// check takes no policy argument. The generated `geometry.css` already keys
38/// its touch gap overrides on the same condition, so the gestures and the
39/// spacing agree by construction rather than by two people remembering.
40///
41/// Emits `cargo:rerun-if-changed` for every file it read.
42///
43/// # Panics
44///
45/// If `js_dir` cannot be read, if no declaration is found, or if any file
46/// disagrees with the crate. A build script has nowhere useful to return an
47/// error to, and a frontend that disagrees with its own stylesheet is worse
48/// than a failed build.
49pub fn check_touch_density(js_dir: impl AsRef<Path>) {
50    let js_dir = js_dir.as_ref();
51    let want = Density::Touch.media_condition();
52    let mut wrong: Vec<String> = Vec::new();
53    let mut found = 0usize;
54
55    let files = js_files(js_dir);
56    for path in &files {
57        let src = std::fs::read_to_string(path).expect("read js file");
58        let name = path
59            .strip_prefix(js_dir)
60            .unwrap_or(path)
61            .display()
62            .to_string();
63
64        for (offset, literal) in touch_density_literals(&src) {
65            found += 1;
66            if literal != want {
67                wrong.push(format!(
68                    "  {name}:{}  {CONST_NAME} = '{literal}'",
69                    line_of(&src, offset)
70                ));
71            }
72        }
73
74        for needle in SNIFFS {
75            if let Some(offset) = src.find(needle) {
76                wrong.push(format!(
77                    "  {name}:{}  {needle} -- device sniff, not a density question",
78                    line_of(&src, offset)
79                ));
80            }
81        }
82    }
83
84    assert!(
85        found > 0,
86        "no {CONST_NAME} literal found under {}.\n\n\
87         A frontend that asks whether it is being touched states\n\
88         makeover_geometry::Density::Touch's media condition in a const of that\n\
89         name, and this check exists to keep every copy equal to it. If the\n\
90         const was renamed, rename it back rather than dropping the check; if\n\
91         this frontend genuinely asks no density question, drop the call.",
92        js_dir.display()
93    );
94
95    assert!(
96        wrong.is_empty(),
97        "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
98         Density::Touch.media_condition() is: {want}\n\n\
99         Wrong:\n{}\n\n\
100         Fix the JS to state the crate's string. Never widen it to catch a\n\
101         device the query misses: density is what is pointing at the screen,\n\
102         and a laptop with a touchscreen and a mouse is a pointer device.",
103        wrong.join("\n")
104    );
105
106    for path in &files {
107        println!("cargo:rerun-if-changed={}", path.display());
108    }
109}
110
111/// Every `.js` file under `dir`, recursively, sorted.
112fn js_files(dir: &Path) -> Vec<PathBuf> {
113    files_with_extension(dir, "js")
114}
115
116/// Every file under `dir` with extension `ext`, recursively, sorted.
117///
118/// Recursive because a consumer's frontend is not always one flat directory:
119/// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
120/// `static/`, and a check that silently skipped the nested half would report
121/// clean on the files most likely to have been copied.
122fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
123    let mut out = Vec::new();
124    let mut stack = vec![dir.to_path_buf()];
125    while let Some(d) = stack.pop() {
126        for entry in std::fs::read_dir(&d)
127            .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
128            .flatten()
129        {
130            let path = entry.path();
131            if path.is_dir() {
132                stack.push(path);
133            } else if path.extension().is_some_and(|x| x == ext) {
134                out.push(path);
135            }
136        }
137    }
138    out.sort();
139    out
140}
141
142/// `(byte offset of the declaration, the literal's contents)` for every
143/// `const TOUCH_DENSITY = '...'` in a JS source.
144fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
145    let mut out = Vec::new();
146    let mut at = 0;
147    while let Some(i) = src[at..].find(CONST_NAME) {
148        let start = at + i;
149        at = start + CONST_NAME.len();
150        // Only the declaration states the string; a use site reads the const.
151        let Some(rest) = src[at..].strip_prefix(" = ") else {
152            continue;
153        };
154        let open = at + " = ".len();
155        let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
156            continue;
157        };
158        let body = open + 1;
159        if let Some(j) = src[body..].find(quote) {
160            out.push((start, &src[body..body + j]));
161            at = body + j + 1;
162        }
163    }
164    out
165}
166
167fn line_of(src: &str, offset: usize) -> usize {
168    src[..offset].matches('\n').count() + 1
169}
170
171/// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
172///
173/// Every pixel width named by a media query under `frontend/css` or
174/// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of
175/// `tuning_widths`.
176///
177/// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
178/// stylesheets and silently leaves every hand-written query behind, and what
179/// you get is not an error but a stylesheet that disagrees with itself at the
180/// old boundary.
181///
182/// `tuning_widths` is the one thing an app gets a say in, which is why this
183/// takes a parameter where [`check_touch_density`] does not. A shell boundary
184/// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is
185/// a point inside a shell where something reflows without the shell changing --
186/// a dashboard dropping from three columns to two, a pane's width cap ending.
187/// Nothing switches shells at one, so it should not move when a size class
188/// does. Pass `&[]` if the app has none, and treat every addition as owing a
189/// note saying what it tunes: the list is where a genuine boundary goes to hide
190/// from this check.
191///
192/// The generated stylesheets are scanned too, and pass by construction: they
193/// ask makeover-geometry for the number rather than stating it. Scanning them
194/// costs nothing and means a consumer never has to name which files are
195/// hand-written.
196///
197/// Emits `cargo:rerun-if-changed` for every file it read.
198///
199/// # Panics
200///
201/// If `frontend/css` or `frontend/js` cannot be read, or if any width is
202/// neither a size-class boundary nor a declared tuning width. A build script
203/// has nowhere useful to return an error to.
204pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
205    let frontend = frontend.as_ref();
206    let allowed = allowed_widths(tuning_widths);
207    let mut stale: Vec<String> = Vec::new();
208
209    let css_files = files_with_extension(&frontend.join("css"), "css");
210    for path in &css_files {
211        let raw = std::fs::read_to_string(path).expect("read css file");
212        // Comments first: a note about a breakpoint that used to be here is
213        // prose, not a rule, and should not fail a build.
214        let src = strip_block_comments(&raw);
215        let name = display_name(frontend, path);
216        for (offset, condition) in media_conditions(&src) {
217            for px in media_widths(condition) {
218                if !allowed.contains(&px) {
219                    stale.push(format!(
220                        "  {name}:{}  @media{condition}  ({px}px)",
221                        line_of(&src, offset)
222                    ));
223                }
224            }
225        }
226    }
227
228    let js_files = js_files(&frontend.join("js"));
229    for path in &js_files {
230        let src = std::fs::read_to_string(path).expect("read js file");
231        let name = display_name(frontend, path);
232        // No declarations in JS, so any width condition is a media query.
233        for (offset, px) in js_widths(&src) {
234            if !allowed.contains(&px) {
235                stale.push(format!("  {name}:{}  ({px}px)", line_of(&src, offset)));
236            }
237        }
238    }
239
240    assert!(
241        stale.is_empty(),
242        "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
243         Allowed: {allowed:?}\n\
244         ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
245         Stale:\n{}\n\n\
246         If a size class moved, update these to match. If one of these is a new\n\
247         tuning width inside the wide shell rather than a shell boundary, add it\n\
248         to the caller's tuning list with a note saying what it tunes.",
249        allowed
250            .iter()
251            .filter(|px| !tuning_widths.contains(px))
252            .collect::<Vec<_>>(),
253        stale.join("\n")
254    );
255
256    for path in css_files.iter().chain(&js_files) {
257        println!("cargo:rerun-if-changed={}", path.display());
258    }
259}
260
261/// A path as the frontend sees it, for an error a reader can act on.
262fn display_name(frontend: &Path, path: &Path) -> String {
263    path.strip_prefix(frontend)
264        .unwrap_or(path)
265        .display()
266        .to_string()
267}
268
269/// Every width a hand-written media query is allowed to name.
270///
271/// Read out of [`SizeClass::media_condition`] rather than typed, which is the
272/// whole point: that is the one place the numbers come from, and a bump in
273/// makeover-geometry has to reach the stylesheet through here.
274fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
275    let mut widths: Vec<u16> = SizeClass::all()
276        .iter()
277        .flat_map(|c| media_widths(&c.media_condition()))
278        .collect();
279    widths.extend_from_slice(tuning_widths);
280    widths.sort_unstable();
281    widths.dedup();
282    widths
283}
284
285/// The pixel values in a media condition, in the order they appear.
286fn media_widths(condition: &str) -> Vec<u16> {
287    let mut out = Vec::new();
288    let mut rest = condition;
289    while let Some(i) = rest.find("-width:") {
290        rest = &rest[i + "-width:".len()..];
291        let digits: String = rest
292            .trim_start()
293            .chars()
294            .take_while(char::is_ascii_digit)
295            .collect();
296        if let Ok(px) = digits.parse() {
297            out.push(px);
298        }
299    }
300    out
301}
302
303/// `(byte offset of the `@media`, the condition text before the `{`)`.
304fn media_conditions(css: &str) -> Vec<(usize, &str)> {
305    let mut out = Vec::new();
306    let mut at = 0;
307    while let Some(i) = css[at..].find("@media") {
308        let start = at + i;
309        let after = start + "@media".len();
310        match css[after..].find('{') {
311            Some(j) => {
312                out.push((start, &css[after..after + j]));
313                at = after + j;
314            }
315            None => break,
316        }
317    }
318    out
319}
320
321/// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
322///
323/// The parentheses are the whole test, and they have to be: a media condition
324/// is always parenthesized and a CSS declaration never is, so `'max-width:
325/// 320px'` in an inline-style string is not a breakpoint and must not read as
326/// one. goingson's shared-updater.js builds exactly that, and the first version
327/// of this check failed the build on it.
328fn js_widths(src: &str) -> Vec<(usize, u16)> {
329    let mut out = Vec::new();
330    for pat in ["(max-width:", "(min-width:"] {
331        let mut at = 0;
332        while let Some(i) = src[at..].find(pat) {
333            let start = at + i;
334            let rest = src[start + pat.len()..].trim_start();
335            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
336            if let Ok(px) = digits.parse()
337                && rest[digits.len()..].starts_with("px)")
338            {
339                out.push((start, px));
340            }
341            at = start + pat.len();
342        }
343    }
344    out
345}
346
347/// Replace every `/* ... */` with spaces, so byte offsets still line up.
348fn strip_block_comments(css: &str) -> String {
349    let bytes = css.as_bytes();
350    let mut out = String::with_capacity(css.len());
351    let mut i = 0;
352    while i < bytes.len() {
353        if bytes[i..].starts_with(b"/*") {
354            let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
355            for c in css[i..end].chars() {
356                out.push(if c == '\n' { '\n' } else { ' ' });
357            }
358            i = end;
359        } else {
360            let c = css[i..].chars().next().unwrap();
361            out.push(c);
362            i += c.len_utf8();
363        }
364    }
365    out
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn scratch(name: &str) -> PathBuf {
373        let dir =
374            std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
375        let _ = std::fs::remove_dir_all(&dir);
376        std::fs::create_dir_all(&dir).expect("create scratch");
377        dir
378    }
379
380    fn write(dir: &Path, name: &str, src: &str) {
381        if let Some(parent) = dir.join(name).parent() {
382            std::fs::create_dir_all(parent).unwrap();
383        }
384        std::fs::write(dir.join(name), src).unwrap();
385    }
386
387    fn declaring() -> String {
388        format!(
389            "const {CONST_NAME} = '{}';\n",
390            Density::Touch.media_condition()
391        )
392    }
393
394    #[test]
395    fn the_crates_own_string_passes() {
396        let dir = scratch("ok");
397        write(&dir, "touch.js", &declaring());
398        check_touch_density(&dir);
399    }
400
401    #[test]
402    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
403    fn a_drifted_literal_fails() {
404        let dir = scratch("drift");
405        write(&dir, "touch.js", &declaring());
406        write(
407            &dir,
408            "haptics.js",
409            &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
410        );
411        check_touch_density(&dir);
412    }
413
414    #[test]
415    #[should_panic(expected = "device sniff")]
416    fn the_sniff_cannot_come_back() {
417        let dir = scratch("sniff");
418        write(&dir, "touch.js", &declaring());
419        write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
420        check_touch_density(&dir);
421    }
422
423    #[test]
424    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
425    fn a_frontend_that_states_nothing_fails() {
426        let dir = scratch("empty");
427        write(&dir, "app.js", "export const x = 1;\n");
428        check_touch_density(&dir);
429    }
430
431    #[test]
432    fn a_use_site_is_not_a_declaration() {
433        // The const is read far more often than it is declared, and a read
434        // states no string. Counting one as a declaration would make the
435        // `found > 0` assertion pass on a frontend that only imports it.
436        let src =
437            format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
438        assert!(touch_density_literals(&src).is_empty());
439    }
440
441    #[test]
442    fn nested_files_are_read() {
443        // The server keeps its scripts in subdirectories, and the nested half
444        // is the half most likely to be a copy.
445        let dir = scratch("nested");
446        write(&dir, "touch.js", &declaring());
447        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
448        let files = js_files(&dir);
449        assert_eq!(files.len(), 2);
450    }
451
452    #[test]
453    fn a_non_js_file_is_ignored() {
454        let dir = scratch("nonjs");
455        write(&dir, "touch.js", &declaring());
456        write(&dir, "styles.css", "body { }\n");
457        assert_eq!(js_files(&dir).len(), 1);
458    }
459
460    fn frontend(name: &str) -> PathBuf {
461        let dir = scratch(name);
462        std::fs::create_dir_all(dir.join("css")).unwrap();
463        std::fs::create_dir_all(dir.join("js")).unwrap();
464        dir
465    }
466
467    /// A width every size class agrees is a boundary.
468    fn boundary() -> u16 {
469        SizeClass::Medium.min_px()
470    }
471
472    #[test]
473    fn the_crates_own_boundaries_pass() {
474        let dir = frontend("bp-ok");
475        write(
476            &dir,
477            "css/styles.css",
478            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
479        );
480        check_breakpoints(&dir, &[]);
481    }
482
483    #[test]
484    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
485    fn a_stale_css_width_fails() {
486        let dir = frontend("bp-css");
487        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
488        check_breakpoints(&dir, &[]);
489    }
490
491    #[test]
492    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
493    fn a_stale_js_width_fails() {
494        let dir = frontend("bp-js");
495        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
496        check_breakpoints(&dir, &[]);
497    }
498
499    #[test]
500    fn a_declared_tuning_width_passes() {
501        let dir = frontend("bp-tuning");
502        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
503        check_breakpoints(&dir, &[1400]);
504    }
505
506    #[test]
507    fn a_width_in_a_comment_is_prose() {
508        // The note explaining which breakpoint used to be here is not a rule,
509        // and failing a build on documentation would teach people to delete it.
510        let dir = frontend("bp-comment");
511        write(
512            &dir,
513            "css/styles.css",
514            "/* was @media (max-width: 768px) until the size classes landed */\n",
515        );
516        check_breakpoints(&dir, &[]);
517    }
518
519    #[test]
520    fn an_unparenthesized_width_is_not_a_breakpoint() {
521        // A JS string building an inline style states `max-width: 320px` with
522        // no parentheses. It is a declaration, not a query, and the first
523        // version of this check failed the build on one.
524        let dir = frontend("bp-inline");
525        write(
526            &dir,
527            "js/style.js",
528            "el.style.cssText = 'max-width: 320px; display: block';\n",
529        );
530        check_breakpoints(&dir, &[]);
531    }
532
533    #[test]
534    fn nested_css_is_read() {
535        // Same argument as the touch check: the nested half is the half most
536        // likely to be a copy.
537        let dir = frontend("bp-nested");
538        write(
539            &dir,
540            "css/screens/detail.css",
541            "@media (max-width: 768px) { }\n",
542        );
543        let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
544        assert!(found.is_err(), "a nested stylesheet must be scanned");
545    }
546
547    #[test]
548    fn the_error_names_the_file_and_line() {
549        let dir = frontend("bp-message");
550        write(
551            &dir,
552            "css/styles.css",
553            "body { }\n@media (max-width: 768px) { }\n",
554        );
555        let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
556        let msg = err
557            .downcast_ref::<String>()
558            .expect("panic payload is a String");
559        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
560    }
561
562    #[test]
563    fn both_quote_styles_read() {
564        let want = Density::Touch.media_condition();
565        for q in ['\'', '"'] {
566            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
567            let found = touch_density_literals(&src);
568            assert_eq!(found.len(), 1);
569            assert_eq!(found[0].1, want);
570        }
571    }
572}