1use std::path::{Path, PathBuf};
15
16use makeover_geometry::{Density, SizeClass};
17
18const CONST_NAME: &str = "TOUCH_DENSITY";
21
22const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
28
29pub 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
111fn js_files(dir: &Path) -> Vec<PathBuf> {
113 files_with_extension(dir, "js")
114}
115
116fn 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
142fn 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 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
171pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
205 let frontend = frontend.as_ref();
206 let mut files = files_with_extension(&frontend.join("css"), "css");
207 files.extend(js_files(&frontend.join("js")));
208 check_paths(&files, tuning_widths, Some(frontend));
209}
210
211pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
231 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
232 check_paths(&paths, tuning_widths, None);
233}
234
235fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
237 let allowed = allowed_widths(tuning_widths);
238 let mut stale: Vec<String> = Vec::new();
239
240 for path in paths {
241 let raw = std::fs::read_to_string(path)
242 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
243 let name = match root {
244 Some(root) => display_name(root, path),
245 None => path.display().to_string(),
246 };
247
248 if path.extension().is_some_and(|x| x == "js") {
249 for (offset, px) in js_widths(&raw) {
251 if !allowed.contains(&px) {
252 stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset)));
253 }
254 }
255 continue;
256 }
257
258 let src = strip_block_comments(&raw);
261 for (offset, condition) in media_conditions(&src) {
262 for px in media_widths(condition) {
263 if !allowed.contains(&px) {
264 stale.push(format!(
265 " {name}:{} @media{condition} ({px}px)",
266 line_of(&src, offset)
267 ));
268 }
269 }
270 }
271 }
272
273 assert!(
274 stale.is_empty(),
275 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
276 Allowed: {allowed:?}\n\
277 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
278 Stale:\n{}\n\n\
279 If a size class moved, update these to match. If one of these is a new\n\
280 tuning width inside the wide shell rather than a shell boundary, add it\n\
281 to the caller's tuning list with a note saying what it tunes.\n\n\
282 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
283 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
284 clamp(). A threshold is for what appears and disappears.",
285 allowed
286 .iter()
287 .filter(|px| !tuning_widths.contains(px))
288 .collect::<Vec<_>>(),
289 stale.join("\n")
290 );
291
292 for path in paths {
293 println!("cargo:rerun-if-changed={}", path.display());
294 }
295}
296
297fn display_name(frontend: &Path, path: &Path) -> String {
299 path.strip_prefix(frontend)
300 .unwrap_or(path)
301 .display()
302 .to_string()
303}
304
305fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
311 let mut widths: Vec<u16> = SizeClass::all()
312 .iter()
313 .flat_map(|c| media_widths(&c.media_condition()))
314 .collect();
315 widths.extend_from_slice(tuning_widths);
316 widths.sort_unstable();
317 widths.dedup();
318 widths
319}
320
321fn media_widths(condition: &str) -> Vec<u16> {
323 let mut out = Vec::new();
324 let mut rest = condition;
325 while let Some(i) = rest.find("-width:") {
326 rest = &rest[i + "-width:".len()..];
327 let digits: String = rest
328 .trim_start()
329 .chars()
330 .take_while(char::is_ascii_digit)
331 .collect();
332 if let Ok(px) = digits.parse() {
333 out.push(px);
334 }
335 }
336 out
337}
338
339fn media_conditions(css: &str) -> Vec<(usize, &str)> {
341 let mut out = Vec::new();
342 let mut at = 0;
343 while let Some(i) = css[at..].find("@media") {
344 let start = at + i;
345 let after = start + "@media".len();
346 match css[after..].find('{') {
347 Some(j) => {
348 out.push((start, &css[after..after + j]));
349 at = after + j;
350 }
351 None => break,
352 }
353 }
354 out
355}
356
357fn js_widths(src: &str) -> Vec<(usize, u16)> {
365 let mut out = Vec::new();
366 for pat in ["(max-width:", "(min-width:"] {
367 let mut at = 0;
368 while let Some(i) = src[at..].find(pat) {
369 let start = at + i;
370 let rest = src[start + pat.len()..].trim_start();
371 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
372 if let Ok(px) = digits.parse()
373 && rest[digits.len()..].starts_with("px)")
374 {
375 out.push((start, px));
376 }
377 at = start + pat.len();
378 }
379 }
380 out
381}
382
383fn strip_block_comments(css: &str) -> String {
385 let bytes = css.as_bytes();
386 let mut out = String::with_capacity(css.len());
387 let mut i = 0;
388 while i < bytes.len() {
389 if bytes[i..].starts_with(b"/*") {
390 let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
391 for c in css[i..end].chars() {
392 out.push(if c == '\n' { '\n' } else { ' ' });
393 }
394 i = end;
395 } else {
396 let c = css[i..].chars().next().unwrap();
397 out.push(c);
398 i += c.len_utf8();
399 }
400 }
401 out
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn scratch(name: &str) -> PathBuf {
409 let dir =
410 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
411 let _ = std::fs::remove_dir_all(&dir);
412 std::fs::create_dir_all(&dir).expect("create scratch");
413 dir
414 }
415
416 fn write(dir: &Path, name: &str, src: &str) {
417 if let Some(parent) = dir.join(name).parent() {
418 std::fs::create_dir_all(parent).unwrap();
419 }
420 std::fs::write(dir.join(name), src).unwrap();
421 }
422
423 fn declaring() -> String {
424 format!(
425 "const {CONST_NAME} = '{}';\n",
426 Density::Touch.media_condition()
427 )
428 }
429
430 #[test]
431 fn the_crates_own_string_passes() {
432 let dir = scratch("ok");
433 write(&dir, "touch.js", &declaring());
434 check_touch_density(&dir);
435 }
436
437 #[test]
438 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
439 fn a_drifted_literal_fails() {
440 let dir = scratch("drift");
441 write(&dir, "touch.js", &declaring());
442 write(
443 &dir,
444 "haptics.js",
445 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
446 );
447 check_touch_density(&dir);
448 }
449
450 #[test]
451 #[should_panic(expected = "device sniff")]
452 fn the_sniff_cannot_come_back() {
453 let dir = scratch("sniff");
454 write(&dir, "touch.js", &declaring());
455 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
456 check_touch_density(&dir);
457 }
458
459 #[test]
460 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
461 fn a_frontend_that_states_nothing_fails() {
462 let dir = scratch("empty");
463 write(&dir, "app.js", "export const x = 1;\n");
464 check_touch_density(&dir);
465 }
466
467 #[test]
468 fn a_use_site_is_not_a_declaration() {
469 let src =
473 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
474 assert!(touch_density_literals(&src).is_empty());
475 }
476
477 #[test]
478 fn nested_files_are_read() {
479 let dir = scratch("nested");
482 write(&dir, "touch.js", &declaring());
483 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
484 let files = js_files(&dir);
485 assert_eq!(files.len(), 2);
486 }
487
488 #[test]
489 fn a_non_js_file_is_ignored() {
490 let dir = scratch("nonjs");
491 write(&dir, "touch.js", &declaring());
492 write(&dir, "styles.css", "body { }\n");
493 assert_eq!(js_files(&dir).len(), 1);
494 }
495
496 fn frontend(name: &str) -> PathBuf {
497 let dir = scratch(name);
498 std::fs::create_dir_all(dir.join("css")).unwrap();
499 std::fs::create_dir_all(dir.join("js")).unwrap();
500 dir
501 }
502
503 fn boundary() -> u16 {
505 SizeClass::Medium.min_px()
506 }
507
508 #[test]
509 fn the_crates_own_boundaries_pass() {
510 let dir = frontend("bp-ok");
511 write(
512 &dir,
513 "css/styles.css",
514 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
515 );
516 check_breakpoints(&dir, &[]);
517 }
518
519 #[test]
520 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
521 fn a_stale_css_width_fails() {
522 let dir = frontend("bp-css");
523 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
524 check_breakpoints(&dir, &[]);
525 }
526
527 #[test]
528 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
529 fn a_stale_js_width_fails() {
530 let dir = frontend("bp-js");
531 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
532 check_breakpoints(&dir, &[]);
533 }
534
535 #[test]
536 fn a_declared_tuning_width_passes() {
537 let dir = frontend("bp-tuning");
538 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
539 check_breakpoints(&dir, &[1400]);
540 }
541
542 #[test]
543 fn a_width_in_a_comment_is_prose() {
544 let dir = frontend("bp-comment");
547 write(
548 &dir,
549 "css/styles.css",
550 "/* was @media (max-width: 768px) until the size classes landed */\n",
551 );
552 check_breakpoints(&dir, &[]);
553 }
554
555 #[test]
556 fn an_unparenthesized_width_is_not_a_breakpoint() {
557 let dir = frontend("bp-inline");
561 write(
562 &dir,
563 "js/style.js",
564 "el.style.cssText = 'max-width: 320px; display: block';\n",
565 );
566 check_breakpoints(&dir, &[]);
567 }
568
569 #[test]
570 fn nested_css_is_read() {
571 let dir = frontend("bp-nested");
574 write(
575 &dir,
576 "css/screens/detail.css",
577 "@media (max-width: 768px) { }\n",
578 );
579 let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
580 assert!(found.is_err(), "a nested stylesheet must be scanned");
581 }
582
583 #[test]
584 fn a_named_list_is_checked() {
585 let dir = frontend("bp-list");
586 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
587 let listed = dir.join("css/style.css");
588 let err =
589 std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
590 let msg = err.downcast_ref::<String>().expect("String payload");
591 assert!(msg.contains("style.css:1"), "got: {msg}");
592 }
593
594 #[test]
595 #[should_panic(expected = "read ")]
596 fn a_listed_file_that_is_gone_fails() {
597 let dir = frontend("bp-missing");
600 check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
601 }
602
603 #[test]
604 fn a_listed_js_file_is_parsed_as_script() {
605 let dir = frontend("bp-list-js");
608 write(
609 &dir,
610 "js/style.js",
611 "el.style.cssText = 'max-width: 320px';\n",
612 );
613 check_breakpoints_files(&[dir.join("js/style.js")], &[]);
614 }
615
616 #[test]
617 fn the_error_names_the_file_and_line() {
618 let dir = frontend("bp-message");
619 write(
620 &dir,
621 "css/styles.css",
622 "body { }\n@media (max-width: 768px) { }\n",
623 );
624 let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
625 let msg = err
626 .downcast_ref::<String>()
627 .expect("panic payload is a String");
628 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
629 }
630
631 #[test]
632 fn both_quote_styles_read() {
633 let want = Density::Touch.media_condition();
634 for q in ['\'', '"'] {
635 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
636 let found = touch_density_literals(&src);
637 assert_eq!(found.len(), 1);
638 assert_eq!(found[0].1, want);
639 }
640 }
641}