1use std::path::{Path, PathBuf};
15
16use makeover_geometry::{Density, SizeClass};
17use makeover_webview::Emit;
18
19const CONST_NAME: &str = "TOUCH_DENSITY";
22
23const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
29
30pub 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).expect("read js file");
59 let name = path
60 .strip_prefix(js_dir)
61 .unwrap_or(path)
62 .display()
63 .to_string();
64
65 for (offset, literal) in touch_density_literals(&src) {
66 found += 1;
67 if literal != want {
68 wrong.push(format!(
69 " {name}:{} {CONST_NAME} = '{literal}'",
70 line_of(&src, offset)
71 ));
72 }
73 }
74
75 for needle in SNIFFS {
76 if let Some(offset) = src.find(needle) {
77 wrong.push(format!(
78 " {name}:{} {needle} -- device sniff, not a density question",
79 line_of(&src, offset)
80 ));
81 }
82 }
83 }
84
85 assert!(
86 found > 0,
87 "no {CONST_NAME} literal found under {}.\n\n\
88 A frontend that asks whether it is being touched states\n\
89 makeover_geometry::Density::Touch's media condition in a const of that\n\
90 name, and this check exists to keep every copy equal to it. If the\n\
91 const was renamed, rename it back rather than dropping the check; if\n\
92 this frontend genuinely asks no density question, drop the call.",
93 js_dir.display()
94 );
95
96 assert!(
97 wrong.is_empty(),
98 "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
99 Density::Touch.media_condition() is: {want}\n\n\
100 Wrong:\n{}\n\n\
101 Fix the JS to state the crate's string. Never widen it to catch a\n\
102 device the query misses: density is what is pointing at the screen,\n\
103 and a laptop with a touchscreen and a mouse is a pointer device.",
104 wrong.join("\n")
105 );
106
107 for path in &files {
108 println!("cargo:rerun-if-changed={}", path.display());
109 }
110}
111
112fn js_files(dir: &Path) -> Vec<PathBuf> {
114 files_with_extension(dir, "js")
115}
116
117fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
124 let mut out = Vec::new();
125 let mut stack = vec![dir.to_path_buf()];
126 while let Some(d) = stack.pop() {
127 for entry in std::fs::read_dir(&d)
128 .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
129 .flatten()
130 {
131 let path = entry.path();
132 if path.is_dir() {
133 stack.push(path);
134 } else if path.extension().is_some_and(|x| x == ext) {
135 out.push(path);
136 }
137 }
138 }
139 out.sort();
140 out
141}
142
143fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
146 let mut out = Vec::new();
147 let mut at = 0;
148 while let Some(i) = src[at..].find(CONST_NAME) {
149 let start = at + i;
150 at = start + CONST_NAME.len();
151 let Some(rest) = src[at..].strip_prefix(" = ") else {
153 continue;
154 };
155 let open = at + " = ".len();
156 let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
157 continue;
158 };
159 let body = open + 1;
160 if let Some(j) = src[body..].find(quote) {
161 out.push((start, &src[body..body + j]));
162 at = body + j + 1;
163 }
164 }
165 out
166}
167
168fn line_of(src: &str, offset: usize) -> usize {
169 src[..offset].matches('\n').count() + 1
170}
171
172pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
206 let frontend = frontend.as_ref();
207 let mut files = files_with_extension(&frontend.join("css"), "css");
208 files.extend(js_files(&frontend.join("js")));
209 check_paths(&files, tuning_widths, Some(frontend));
210}
211
212pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
232 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
233 check_paths(&paths, tuning_widths, None);
234}
235
236fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
238 let allowed = allowed_widths(tuning_widths);
239 let mut stale: Vec<String> = Vec::new();
240
241 for path in paths {
242 let raw = std::fs::read_to_string(path)
243 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
244 let name = match root {
245 Some(root) => display_name(root, path),
246 None => path.display().to_string(),
247 };
248
249 if path.extension().is_some_and(|x| x == "js") {
250 for (offset, px) in js_widths(&raw) {
252 if !allowed.contains(&px) {
253 stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset)));
254 }
255 }
256 continue;
257 }
258
259 let src = strip_block_comments(&raw);
262 for (offset, condition) in media_conditions(&src) {
263 for px in media_widths(condition) {
264 if !allowed.contains(&px) {
265 stale.push(format!(
266 " {name}:{} @media{condition} ({px}px)",
267 line_of(&src, offset)
268 ));
269 }
270 }
271 }
272 }
273
274 assert!(
275 stale.is_empty(),
276 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
277 Allowed: {allowed:?}\n\
278 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
279 Stale:\n{}\n\n\
280 If a size class moved, update these to match. If one of these is a new\n\
281 tuning width inside the wide shell rather than a shell boundary, add it\n\
282 to the caller's tuning list with a note saying what it tunes.\n\n\
283 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
284 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
285 clamp(). A threshold is for what appears and disappears.",
286 allowed
287 .iter()
288 .filter(|px| !tuning_widths.contains(px))
289 .collect::<Vec<_>>(),
290 stale.join("\n")
291 );
292
293 for path in paths {
294 println!("cargo:rerun-if-changed={}", path.display());
295 }
296}
297
298fn display_name(frontend: &Path, path: &Path) -> String {
300 path.strip_prefix(frontend)
301 .unwrap_or(path)
302 .display()
303 .to_string()
304}
305
306fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
312 let mut widths: Vec<u16> = SizeClass::all()
313 .iter()
314 .flat_map(|c| media_widths(&c.media_condition()))
315 .collect();
316 widths.extend_from_slice(tuning_widths);
317 widths.sort_unstable();
318 widths.dedup();
319 widths
320}
321
322fn media_widths(condition: &str) -> Vec<u16> {
324 let mut out = Vec::new();
325 let mut rest = condition;
326 while let Some(i) = rest.find("-width:") {
327 rest = &rest[i + "-width:".len()..];
328 let digits: String = rest
329 .trim_start()
330 .chars()
331 .take_while(char::is_ascii_digit)
332 .collect();
333 if let Ok(px) = digits.parse() {
334 out.push(px);
335 }
336 }
337 out
338}
339
340fn media_conditions(css: &str) -> Vec<(usize, &str)> {
342 let mut out = Vec::new();
343 let mut at = 0;
344 while let Some(i) = css[at..].find("@media") {
345 let start = at + i;
346 let after = start + "@media".len();
347 match css[after..].find('{') {
348 Some(j) => {
349 out.push((start, &css[after..after + j]));
350 at = after + j;
351 }
352 None => break,
353 }
354 }
355 out
356}
357
358fn js_widths(src: &str) -> Vec<(usize, u16)> {
366 let mut out = Vec::new();
367 for pat in ["(max-width:", "(min-width:"] {
368 let mut at = 0;
369 while let Some(i) = src[at..].find(pat) {
370 let start = at + i;
371 let rest = src[start + pat.len()..].trim_start();
372 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
373 if let Ok(px) = digits.parse()
374 && rest[digits.len()..].starts_with("px)")
375 {
376 out.push((start, px));
377 }
378 at = start + pat.len();
379 }
380 }
381 out
382}
383
384fn strip_block_comments(css: &str) -> String {
386 let bytes = css.as_bytes();
387 let mut out = String::with_capacity(css.len());
388 let mut i = 0;
389 while i < bytes.len() {
390 if bytes[i..].starts_with(b"/*") {
391 let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
392 for c in css[i..end].chars() {
393 out.push(if c == '\n' { '\n' } else { ' ' });
394 }
395 i = end;
396 } else {
397 let c = css[i..].chars().next().unwrap();
398 out.push(c);
399 i += c.len_utf8();
400 }
401 }
402 out
403}
404
405pub fn check_vocabulary(frontend: impl AsRef<Path>, opts: &Emit, generated: &[&str]) {
428 let frontend = frontend.as_ref();
429 let css = frontend.join("css");
430 let files: Vec<PathBuf> = files_with_extension(&css, "css")
431 .into_iter()
432 .filter(|p| {
433 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
434 !generated.contains(&name.as_str())
435 })
436 .collect();
437 check_vocabulary_paths(&files, opts, Some(frontend));
438}
439
440pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit) {
452 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
453 check_vocabulary_paths(&paths, opts, None);
454}
455
456fn check_vocabulary_paths(paths: &[PathBuf], opts: &Emit, root: Option<&Path>) {
458 let generated = makeover_webview::vocabulary::vocabulary(opts);
459 let mut clashes: Vec<String> = Vec::new();
460
461 for path in paths {
462 println!("cargo::rerun-if-changed={}", path.display());
463 let raw = std::fs::read_to_string(path)
464 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
465 let name = match root {
466 Some(root) => display_name(root, path),
467 None => path.display().to_string(),
468 };
469 let local = makeover_webview::vocabulary::classes_in_css(&raw);
472 for class in local.intersection(&generated) {
473 clashes.push(format!(" {name} .{class}"));
474 }
475 }
476
477 assert!(
478 clashes.is_empty(),
479 "{} hand-written rule(s) re-specify a class the generated stylesheet already \
480 defines. App CSS is unlayered and beats @layer makeover, so each of these wins \
481 over the design system silently:\n{}\n\nDelete the local rule, or, if it adds \
482 something makeover does not answer for, move the addition onto a class of the \
483 app's own. Count the consumers before deciding it is a divergence worth keeping.",
484 clashes.len(),
485 clashes.join("\n")
486 );
487}
488
489pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
516 let generated = makeover_webview::vocabulary::names(opts);
517 let mut haystack = String::new();
518 for path in markup {
519 let path = path.as_ref();
520 println!("cargo::rerun-if-changed={}", path.display());
521 haystack.push_str(
522 &std::fs::read_to_string(path)
523 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
524 );
525 haystack.push('\n');
526 }
527
528 let unused: Vec<&String> = generated
529 .iter()
530 .filter(|class| !haystack.contains(class.as_str()))
531 .collect();
532
533 assert!(
534 unused.len() <= high_water,
535 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
536 The vocabulary grew or the markup stopped using it:\n{}",
537 unused.len(),
538 generated.len(),
539 high_water,
540 unused
541 .iter()
542 .map(|c| format!(" .{c}"))
543 .collect::<Vec<_>>()
544 .join("\n")
545 );
546
547 if unused.len() < high_water {
548 println!(
549 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
550 lower the seal so it cannot grow back",
551 unused.len(),
552 high_water
553 );
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn scratch(name: &str) -> PathBuf {
562 let dir =
563 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
564 let _ = std::fs::remove_dir_all(&dir);
565 std::fs::create_dir_all(&dir).expect("create scratch");
566 dir
567 }
568
569 fn write(dir: &Path, name: &str, src: &str) {
570 if let Some(parent) = dir.join(name).parent() {
571 std::fs::create_dir_all(parent).unwrap();
572 }
573 std::fs::write(dir.join(name), src).unwrap();
574 }
575
576 fn declaring() -> String {
577 format!(
578 "const {CONST_NAME} = '{}';\n",
579 Density::Touch.media_condition()
580 )
581 }
582
583 #[test]
584 fn the_crates_own_string_passes() {
585 let dir = scratch("ok");
586 write(&dir, "touch.js", &declaring());
587 check_touch_density(&dir);
588 }
589
590 #[test]
591 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
592 fn a_drifted_literal_fails() {
593 let dir = scratch("drift");
594 write(&dir, "touch.js", &declaring());
595 write(
596 &dir,
597 "haptics.js",
598 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
599 );
600 check_touch_density(&dir);
601 }
602
603 #[test]
604 #[should_panic(expected = "device sniff")]
605 fn the_sniff_cannot_come_back() {
606 let dir = scratch("sniff");
607 write(&dir, "touch.js", &declaring());
608 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
609 check_touch_density(&dir);
610 }
611
612 #[test]
613 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
614 fn a_frontend_that_states_nothing_fails() {
615 let dir = scratch("empty");
616 write(&dir, "app.js", "export const x = 1;\n");
617 check_touch_density(&dir);
618 }
619
620 #[test]
621 fn a_use_site_is_not_a_declaration() {
622 let src =
626 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
627 assert!(touch_density_literals(&src).is_empty());
628 }
629
630 #[test]
631 fn nested_files_are_read() {
632 let dir = scratch("nested");
635 write(&dir, "touch.js", &declaring());
636 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
637 let files = js_files(&dir);
638 assert_eq!(files.len(), 2);
639 }
640
641 #[test]
642 fn a_non_js_file_is_ignored() {
643 let dir = scratch("nonjs");
644 write(&dir, "touch.js", &declaring());
645 write(&dir, "styles.css", "body { }\n");
646 assert_eq!(js_files(&dir).len(), 1);
647 }
648
649 fn frontend(name: &str) -> PathBuf {
650 let dir = scratch(name);
651 std::fs::create_dir_all(dir.join("css")).unwrap();
652 std::fs::create_dir_all(dir.join("js")).unwrap();
653 dir
654 }
655
656 fn boundary() -> u16 {
658 SizeClass::Medium.min_px()
659 }
660
661 #[test]
662 fn the_crates_own_boundaries_pass() {
663 let dir = frontend("bp-ok");
664 write(
665 &dir,
666 "css/styles.css",
667 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
668 );
669 check_breakpoints(&dir, &[]);
670 }
671
672 #[test]
673 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
674 fn a_stale_css_width_fails() {
675 let dir = frontend("bp-css");
676 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
677 check_breakpoints(&dir, &[]);
678 }
679
680 #[test]
681 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
682 fn a_stale_js_width_fails() {
683 let dir = frontend("bp-js");
684 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
685 check_breakpoints(&dir, &[]);
686 }
687
688 #[test]
689 fn a_declared_tuning_width_passes() {
690 let dir = frontend("bp-tuning");
691 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
692 check_breakpoints(&dir, &[1400]);
693 }
694
695 #[test]
696 fn a_width_in_a_comment_is_prose() {
697 let dir = frontend("bp-comment");
700 write(
701 &dir,
702 "css/styles.css",
703 "/* was @media (max-width: 768px) until the size classes landed */\n",
704 );
705 check_breakpoints(&dir, &[]);
706 }
707
708 #[test]
709 fn an_unparenthesized_width_is_not_a_breakpoint() {
710 let dir = frontend("bp-inline");
714 write(
715 &dir,
716 "js/style.js",
717 "el.style.cssText = 'max-width: 320px; display: block';\n",
718 );
719 check_breakpoints(&dir, &[]);
720 }
721
722 #[test]
723 fn nested_css_is_read() {
724 let dir = frontend("bp-nested");
727 write(
728 &dir,
729 "css/screens/detail.css",
730 "@media (max-width: 768px) { }\n",
731 );
732 let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
733 assert!(found.is_err(), "a nested stylesheet must be scanned");
734 }
735
736 #[test]
737 fn a_named_list_is_checked() {
738 let dir = frontend("bp-list");
739 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
740 let listed = dir.join("css/style.css");
741 let err =
742 std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
743 let msg = err.downcast_ref::<String>().expect("String payload");
744 assert!(msg.contains("style.css:1"), "got: {msg}");
745 }
746
747 #[test]
748 #[should_panic(expected = "read ")]
749 fn a_listed_file_that_is_gone_fails() {
750 let dir = frontend("bp-missing");
753 check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
754 }
755
756 #[test]
757 fn a_listed_js_file_is_parsed_as_script() {
758 let dir = frontend("bp-list-js");
761 write(
762 &dir,
763 "js/style.js",
764 "el.style.cssText = 'max-width: 320px';\n",
765 );
766 check_breakpoints_files(&[dir.join("js/style.js")], &[]);
767 }
768
769 #[test]
770 fn the_error_names_the_file_and_line() {
771 let dir = frontend("bp-message");
772 write(
773 &dir,
774 "css/styles.css",
775 "body { }\n@media (max-width: 768px) { }\n",
776 );
777 let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
778 let msg = err
779 .downcast_ref::<String>()
780 .expect("panic payload is a String");
781 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
782 }
783
784 #[test]
785 fn a_rule_restating_a_generated_class_fails_and_names_it() {
786 let dir = scratch("vocab-clash");
787 write(
790 &dir,
791 "css/styles.css",
792 "body { color: red; }\n.card { box-shadow: none; }\n",
793 );
794 let err =
795 std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
796 let msg = err
797 .downcast_ref::<String>()
798 .expect("panic payload is a String");
799 assert!(msg.contains(".card"), "got: {msg}");
800 assert!(msg.contains("css/styles.css"), "got: {msg}");
801 }
802
803 #[test]
804 fn an_app_class_of_its_own_is_left_alone() {
805 let dir = scratch("vocab-clean");
806 write(
807 &dir,
808 "css/styles.css",
809 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
810 );
811 check_vocabulary(&dir, &Emit::default(), &[]);
812 }
813
814 #[test]
815 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
816 let dir = scratch("vocab-generated");
817 let opts = Emit::default();
818 write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
819 check_vocabulary(&dir, &opts, &["layout.css"]);
822 }
823
824 #[test]
825 fn a_prefixed_app_is_checked_against_its_own_prefix() {
826 let dir = scratch("vocab-prefix");
827 let opts = Emit {
828 class_prefix: "mo-",
829 ..Emit::default()
830 };
831 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
834 check_vocabulary(&dir, &opts, &[]);
835
836 let dir = scratch("vocab-prefix-clash");
837 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
838 assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
839 }
840
841 #[test]
842 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
843 let dir = scratch("vocab-seal");
844 let opts = Emit::default();
845 let all = makeover_webview::vocabulary::names(&opts).len();
846 write(&dir, "index.html", "<div></div>\n");
848 let markup = [dir.join("index.html")];
849
850 check_vocabulary_use(&markup, &opts, all);
851 assert!(
852 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
853 "a vocabulary deader than the seal has to fail"
854 );
855 }
856
857 #[test]
858 fn both_quote_styles_read() {
859 let want = Density::Touch.media_condition();
860 for q in ['\'', '"'] {
861 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
862 let found = touch_density_literals(&src);
863 assert_eq!(found.len(), 1);
864 assert_eq!(found[0].1, want);
865 }
866 }
867}