1use std::path::{Path, PathBuf};
15
16use crate::Emit;
17use makeover_geometry::{Density, SizeClass};
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)
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 println!("cargo:rerun-if-changed={}", js_dir.display());
115}
116
117fn js_files(dir: &Path) -> Vec<PathBuf> {
119 files_with_extension(dir, "js")
120}
121
122fn 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
148fn 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 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#[derive(Debug, Clone)]
179enum Source {
180 Frontend(PathBuf),
182 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#[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 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 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
238 Self {
239 source: Source::files(paths),
240 tuning: Vec::new(),
241 }
242 }
243
244 pub fn tuning(mut self, widths: &[u16]) -> Self {
254 self.tuning.extend_from_slice(widths);
255 self
256 }
257
258 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 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
282fn 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 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 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
346fn display_name(frontend: &Path, path: &Path) -> String {
348 path.strip_prefix(frontend)
349 .unwrap_or(path)
350 .display()
351 .to_string()
352}
353
354fn 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#[derive(Debug, Clone, PartialEq, Eq)]
378enum Width {
379 Px(u16),
382 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
402fn media_widths(condition: &str) -> Vec<Width> {
410 width_groups(condition)
411 .into_iter()
412 .flat_map(|(_, group)| lengths(group))
413 .collect()
414}
415
416fn 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
439fn 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
457fn 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 #[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
501fn 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
519fn 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
534fn 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#[derive(Clone, Copy, PartialEq, Eq)]
549enum Lang {
550 Css,
553 Js,
557}
558
559fn blank(src: &str, lang: Lang) -> String {
565 #[derive(Clone, Copy)]
566 enum State {
567 Code,
568 Block,
569 Line,
570 Quoted(char),
571 }
572 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
643fn 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 layers_ordered_below_makeover(css: &str) -> Vec<String> {
705 let scan = blank(css, Lang::Css);
706 let mut below = Vec::new();
707 for statement in scan.split(';') {
708 let Some(at) = statement.rfind("@layer") else {
709 continue;
710 };
711 let names: Vec<&str> = statement[at + "@layer".len()..]
712 .split(',')
713 .map(str::trim)
714 .collect();
715 if names.iter().any(|name| name.contains(['{', '}'])) {
716 continue;
717 }
718 if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
719 below.extend(names[..position].iter().map(|name| (*name).to_string()));
720 }
721 }
722 below
723}
724
725fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
726 haystack
727 .get(from..)?
728 .windows(needle.len())
729 .position(|window| window == needle)
730 .map(|at| at + from)
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740pub enum Allow<'a> {
741 Class {
743 class: &'a str,
745 property: &'a str,
747 },
748 Element {
751 element: &'a str,
753 class: &'a str,
755 property: &'a str,
757 },
758}
759
760impl<'a> Allow<'a> {
761 pub const fn class(class: &'a str, property: &'a str) -> Self {
763 Self::Class { class, property }
764 }
765
766 pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
768 Self::Element {
769 element,
770 class,
771 property,
772 }
773 }
774}
775
776#[derive(Debug, Clone)]
853#[must_use = "a check does nothing until it is run"]
854pub struct Vocabulary<'a> {
855 source: Source,
856 generated: Vec<&'a str>,
857 upstream: Vec<&'a str>,
858 allowed: Vec<Allow<'a>>,
859 beneath: Vec<&'a str>,
860}
861
862impl<'a> Vocabulary<'a> {
863 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
865 Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
866 }
867
868 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
874 Self::from(Source::files(paths))
875 }
876
877 fn from(source: Source) -> Self {
878 Self {
879 source,
880 generated: Vec::new(),
881 upstream: Vec::new(),
882 allowed: Vec::new(),
883 beneath: Vec::new(),
884 }
885 }
886
887 pub fn generated(mut self, names: &[&'a str]) -> Self {
892 self.generated.extend_from_slice(names);
893 self
894 }
895
896 pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
906 self.upstream.extend_from_slice(sheets);
907 self
908 }
909
910 pub fn beneath(mut self, layers: &[&'a str]) -> Self {
920 self.beneath.extend_from_slice(layers);
921 self
922 }
923
924 pub fn allow(mut self, allow: Allow<'a>) -> Self {
926 self.allowed.push(allow);
927 self
928 }
929
930 pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
932 self.allowed.extend(allows);
933 self
934 }
935
936 pub fn run(&self, opts: &Emit) {
947 let classes: Vec<(&str, &str)> = self
948 .allowed
949 .iter()
950 .filter_map(|allow| match *allow {
951 Allow::Class { class, property } => Some((class, property)),
952 Allow::Element { .. } => None,
953 })
954 .collect();
955 let elements: Vec<(&str, &str, &str)> = self
956 .allowed
957 .iter()
958 .filter_map(|allow| match *allow {
959 Allow::Element {
960 element,
961 class,
962 property,
963 } => Some((element, class, property)),
964 Allow::Class { .. } => None,
965 })
966 .collect();
967 match &self.source {
968 Source::Frontend(frontend) => {
969 let css = frontend.join("css");
970 let files: Vec<PathBuf> = files_with_extension(&css, "css")
971 .into_iter()
972 .filter(|p| {
973 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
974 !self.generated.contains(&name.as_str())
975 })
976 .collect();
977 check_vocabulary_paths(
978 &files,
979 opts,
980 Some(frontend),
981 &self.upstream,
982 &self.beneath,
983 &classes,
984 &elements,
985 );
986 println!("cargo::rerun-if-changed={}", css.display());
988 }
989 Source::Files(paths) => {
990 let files: Vec<PathBuf> = paths
991 .iter()
992 .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
993 .cloned()
994 .collect();
995 check_vocabulary_paths(
996 &files,
997 opts,
998 None,
999 &self.upstream,
1000 &self.beneath,
1001 &classes,
1002 &elements,
1003 );
1004 }
1005 }
1006 }
1007}
1008
1009fn check_vocabulary_paths(
1011 paths: &[PathBuf],
1012 opts: &Emit,
1013 root: Option<&Path>,
1014 upstream: &[&str],
1015 beneath: &[&str],
1016 allowed: &[(&str, &str)],
1017 allowed_elements: &[(&str, &str, &str)],
1018) {
1019 let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
1020 let mut owned = makeover.clone();
1023 for sheet in upstream {
1024 for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
1025 owned.entry(class).or_default().extend(properties);
1026 }
1027 }
1028 let makeover_variants = crate::vocabulary::variants_by_class(&crate::stylesheet(opts));
1031 let mut owned_variants = makeover_variants.clone();
1032 for sheet in upstream {
1033 for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
1034 let entry = owned_variants.entry(class).or_default();
1035 for (arm, properties) in arms {
1036 entry.entry(arm).or_default().extend(properties);
1037 }
1038 }
1039 }
1040 let mut cancels: Vec<String> = Vec::new();
1041 let mut clashes: Vec<String> = Vec::new();
1042 let mut seen: Vec<(String, String)> = Vec::new();
1043 let mut element_clashes: Vec<String> = Vec::new();
1044 let mut element_seen: Vec<(String, String, String)> = Vec::new();
1045
1046 for path in paths {
1047 println!("cargo::rerun-if-changed={}", path.display());
1048 let raw = std::fs::read_to_string(path)
1049 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1050 let name = match root {
1051 Some(root) => display_name(root, path),
1052 None => path.display().to_string(),
1053 };
1054 let (generated, variants) = if upstream.contains(&raw.as_str()) {
1057 (&makeover, &makeover_variants)
1058 } else {
1059 (&owned, &owned_variants)
1060 };
1061 let raw = drop_layers(&raw, beneath);
1064 let local = crate::vocabulary::declarations_by_class(&raw);
1067 for (class, properties) in &local {
1068 let Some(theirs) = generated.get(class) else {
1069 continue;
1070 };
1071 for property in properties.intersection(theirs) {
1072 seen.push((class.clone(), property.clone()));
1073 if allowed.contains(&(class.as_str(), property.as_str())) {
1074 continue;
1075 }
1076 clashes.push(format!(" {name} .{class} {{ {property} }}"));
1077 }
1078 }
1079
1080 let own_below = layers_ordered_below_makeover(&raw);
1091 let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
1092 let above = drop_layers(&raw, &own_below);
1093 let bare = crate::vocabulary::bare_declarations(&above);
1094 let restated = crate::vocabulary::mentions_by_arm(&raw);
1095 for (class, properties) in &bare {
1096 let Some(arms) = variants.get(class) else {
1097 continue;
1098 };
1099 for property in properties {
1100 let lost: Vec<&str> = arms
1101 .iter()
1102 .filter(|(arm, theirs)| {
1103 theirs.contains(property)
1104 && !restated
1105 .get(arm.as_str())
1106 .is_some_and(|mentioned| mentioned.contains(property))
1107 })
1108 .map(|(arm, _)| arm.as_str())
1109 .collect();
1110 if lost.is_empty() {
1111 continue;
1112 }
1113 cancels.push(format!(
1114 " {name} .{class} {{ {property} }} cancels {}",
1115 lost.iter()
1116 .map(|arm| format!("{arm} {{ {property} }}"))
1117 .collect::<Vec<_>>()
1118 .join(", ")
1119 ));
1120 }
1121 }
1122
1123 let mentioned = crate::vocabulary::mentions_by_class(&raw);
1129 let by_element = crate::vocabulary::declarations_by_element(&raw);
1130 for (element, properties) in &by_element {
1131 for class in crate::vocabulary::classes_for_element(element, opts) {
1132 let Some(theirs) = generated.get(&class) else {
1133 continue;
1134 };
1135 for (property, rank) in properties {
1136 if !theirs.contains(property) {
1137 continue;
1138 }
1139 let spoken_for = mentioned
1143 .get(&class)
1144 .and_then(|properties| properties.get(property))
1145 .is_some_and(|theirs| theirs >= rank);
1146 if spoken_for {
1147 continue;
1148 }
1149 element_seen.push((element.clone(), class.clone(), property.clone()));
1150 if allowed_elements.contains(&(
1151 element.as_str(),
1152 class.as_str(),
1153 property.as_str(),
1154 )) {
1155 continue;
1156 }
1157 element_clashes.push(format!(
1158 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
1159 ));
1160 }
1161 }
1162 }
1163 }
1164
1165 assert!(
1166 cancels.is_empty(),
1167 "{} hand-written rule(s) on a bare generated class set a property the \
1168 design system varies on that class by state, tone or context. The app \
1169 sheet sits in a later cascade layer than @layer makeover, and layers \
1170 resolve before specificity, so each of these beats every variant named \
1171 beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
1172 cancelled variant in the app's sheet on its own arm, after the bare rule \
1173 (`color: revert-layer` hands the design system's value back). That is \
1174 the remedy the cascade allows; an allowed-list entry does not cover \
1175 this, because the reviewed pair is the class and the variants are \
1176 what it silently takes.",
1177 cancels.len(),
1178 cancels.join("\n")
1179 );
1180
1181 assert!(
1182 clashes.is_empty(),
1183 "{} hand-written declaration(s) take a property the generated stylesheet, \
1184 or an upstream sheet named to this check, already sets on the same \
1185 class. App CSS wins over @layer makeover, whether by a later layer or \
1186 by being unlayered, so each of these wins over the design system \
1187 silently:\n{}\n\nDelete the declaration, or, if \
1188 it is a deliberate pairing on a different selector arm, add \
1189 (class, property) to this check's allowed list and say why beside it. \
1190 Count the consumers before deciding a divergence is worth keeping.",
1191 clashes.len(),
1192 clashes.join("\n")
1193 );
1194
1195 assert!(
1196 element_clashes.is_empty(),
1197 "{} hand-written element rule(s) take a property the generated \
1198 stylesheet sets on a class that element carries. App CSS wins over \
1199 @layer makeover, whether by a later layer or by being unlayered, so a \
1200 described component rendered on one of these elements loses the \
1201 design system's version of that property silently, which can draw a \
1202 destructive act like an ordinary one:\n{}\n\nHand the \
1203 property back on the arms makeover paints \
1204 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1205 rule so it stops reaching described markup, or add \
1206 (element, class, property) to this check's allowed-elements list and \
1207 say why beside it.",
1208 element_clashes.len(),
1209 element_clashes.join("\n")
1210 );
1211
1212 let stale: Vec<&(&str, &str)> = allowed
1213 .iter()
1214 .filter(|(class, property)| {
1215 !seen.contains(&((*class).to_string(), (*property).to_string()))
1216 })
1217 .collect();
1218 assert!(
1219 stale.is_empty(),
1220 "the allowed list declares {stale:?}, which no longer collides with \
1221 anything. Delete the entries: an exception nobody is using is where the \
1222 next real collision lands and reads as company."
1223 );
1224
1225 let stale: Vec<&(&str, &str, &str)> = allowed_elements
1226 .iter()
1227 .filter(|(element, class, property)| {
1228 !element_seen.contains(&(
1229 (*element).to_string(),
1230 (*class).to_string(),
1231 (*property).to_string(),
1232 ))
1233 })
1234 .collect();
1235 assert!(
1236 stale.is_empty(),
1237 "the allowed-elements list declares {stale:?}, which no longer collides \
1238 with anything. Delete the entries: an exception nobody is using is \
1239 where the next real collision lands and reads as company."
1240 );
1241}
1242
1243fn haystack_names(haystack: &str, class: &str) -> bool {
1247 let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
1248 haystack.match_indices(class).any(|(at, found)| {
1249 haystack[..at].chars().next_back().is_none_or(|c| !part(c))
1250 && haystack[at + found.len()..]
1251 .chars()
1252 .next()
1253 .is_none_or(|c| !part(c))
1254 })
1255}
1256
1257pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1281 let generated = crate::vocabulary::names(opts);
1282 let mut haystack = String::new();
1283 for path in markup {
1284 let path = path.as_ref();
1285 println!("cargo::rerun-if-changed={}", path.display());
1286 haystack.push_str(
1287 &std::fs::read_to_string(path)
1288 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
1289 );
1290 haystack.push('\n');
1291 }
1292
1293 const LADDERS: &[&str] = &["min-", "short-"];
1299 let rung_of = |class: &str| -> Option<&'static str> {
1300 let name = class.strip_prefix(opts.class_prefix)?;
1301 LADDERS.iter().copied().find(|ladder| {
1302 name.strip_prefix(ladder)
1303 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1304 })
1305 };
1306 let mut unused: Vec<String> = generated
1307 .iter()
1308 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1309 .cloned()
1310 .collect();
1311 for ladder in LADDERS {
1312 let rungs: Vec<&String> = generated
1313 .iter()
1314 .filter(|class| rung_of(class) == Some(ladder))
1315 .collect();
1316 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1317 unused.push(format!("{}{ladder}N", opts.class_prefix));
1318 }
1319 }
1320
1321 assert!(
1322 unused.len() <= high_water,
1323 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1324 The vocabulary grew or the markup stopped using it:\n{}",
1325 unused.len(),
1326 generated.len(),
1327 high_water,
1328 unused
1329 .iter()
1330 .map(|c| format!(" .{c}"))
1331 .collect::<Vec<_>>()
1332 .join("\n")
1333 );
1334
1335 if unused.len() < high_water {
1336 println!(
1337 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1338 lower the seal so it cannot grow back",
1339 unused.len(),
1340 high_water
1341 );
1342 }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348 use std::fmt::Write as _;
1349
1350 fn scratch(name: &str) -> PathBuf {
1351 let dir =
1352 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1353 let _ = std::fs::remove_dir_all(&dir);
1354 std::fs::create_dir_all(&dir).expect("create scratch");
1355 dir
1356 }
1357
1358 fn write(dir: &Path, name: &str, src: &str) {
1359 if let Some(parent) = dir.join(name).parent() {
1360 std::fs::create_dir_all(parent).unwrap();
1361 }
1362 std::fs::write(dir.join(name), src).unwrap();
1363 }
1364
1365 fn declaring() -> String {
1366 format!(
1367 "const {CONST_NAME} = '{}';\n",
1368 Density::Touch.media_condition()
1369 )
1370 }
1371
1372 #[test]
1373 fn the_crates_own_string_passes() {
1374 let dir = scratch("ok");
1375 write(&dir, "touch.js", &declaring());
1376 check_touch_density(&dir);
1377 }
1378
1379 #[test]
1380 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1381 fn a_drifted_literal_fails() {
1382 let dir = scratch("drift");
1383 write(&dir, "touch.js", &declaring());
1384 write(
1385 &dir,
1386 "haptics.js",
1387 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1388 );
1389 check_touch_density(&dir);
1390 }
1391
1392 #[test]
1393 #[should_panic(expected = "device sniff")]
1394 fn the_sniff_cannot_come_back() {
1395 let dir = scratch("sniff");
1396 write(&dir, "touch.js", &declaring());
1397 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1398 check_touch_density(&dir);
1399 }
1400
1401 #[test]
1402 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1403 fn a_frontend_that_states_nothing_fails() {
1404 let dir = scratch("empty");
1405 write(&dir, "app.js", "export const x = 1;\n");
1406 check_touch_density(&dir);
1407 }
1408
1409 #[test]
1410 fn a_use_site_is_not_a_declaration() {
1411 let src =
1415 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1416 assert!(touch_density_literals(&src).is_empty());
1417 }
1418
1419 #[test]
1420 fn nested_files_are_read() {
1421 let dir = scratch("nested");
1424 write(&dir, "touch.js", &declaring());
1425 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1426 let files = js_files(&dir);
1427 assert_eq!(files.len(), 2);
1428 }
1429
1430 #[test]
1431 fn a_non_js_file_is_ignored() {
1432 let dir = scratch("nonjs");
1433 write(&dir, "touch.js", &declaring());
1434 write(&dir, "styles.css", "body { }\n");
1435 assert_eq!(js_files(&dir).len(), 1);
1436 }
1437
1438 fn frontend(name: &str) -> PathBuf {
1439 let dir = scratch(name);
1440 std::fs::create_dir_all(dir.join("css")).unwrap();
1441 std::fs::create_dir_all(dir.join("js")).unwrap();
1442 dir
1443 }
1444
1445 fn boundary() -> u16 {
1447 SizeClass::Medium.min_px()
1448 }
1449
1450 #[test]
1451 fn the_crates_own_boundaries_pass() {
1452 let dir = frontend("bp-ok");
1453 write(
1454 &dir,
1455 "css/styles.css",
1456 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1457 );
1458 Breakpoints::in_frontend(&dir).run();
1459 }
1460
1461 #[test]
1462 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1463 fn a_stale_css_width_fails() {
1464 let dir = frontend("bp-css");
1465 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1466 Breakpoints::in_frontend(&dir).run();
1467 }
1468
1469 #[test]
1470 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1471 fn a_stale_js_width_fails() {
1472 let dir = frontend("bp-js");
1473 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1474 Breakpoints::in_frontend(&dir).run();
1475 }
1476
1477 #[test]
1478 fn a_declared_tuning_width_passes() {
1479 let dir = frontend("bp-tuning");
1480 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1481 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1482 }
1483
1484 #[test]
1485 fn a_width_in_a_comment_is_prose() {
1486 let dir = frontend("bp-comment");
1489 write(
1490 &dir,
1491 "css/styles.css",
1492 "/* was @media (max-width: 768px) until the size classes landed */\n",
1493 );
1494 Breakpoints::in_frontend(&dir).run();
1495 }
1496
1497 #[test]
1498 fn an_unparenthesized_width_is_not_a_breakpoint() {
1499 let dir = frontend("bp-inline");
1503 write(
1504 &dir,
1505 "js/style.js",
1506 "el.style.cssText = 'max-width: 320px; display: block';\n",
1507 );
1508 Breakpoints::in_frontend(&dir).run();
1509 }
1510
1511 #[test]
1512 fn nested_css_is_read() {
1513 let dir = frontend("bp-nested");
1516 write(
1517 &dir,
1518 "css/screens/detail.css",
1519 "@media (max-width: 768px) { }\n",
1520 );
1521 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1522 assert!(found.is_err(), "a nested stylesheet must be scanned");
1523 }
1524
1525 #[test]
1526 fn a_named_list_is_checked() {
1527 let dir = frontend("bp-list");
1528 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1529 let listed = dir.join("css/style.css");
1530 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1531 let msg = err.downcast_ref::<String>().expect("String payload");
1532 assert!(msg.contains("style.css:1"), "got: {msg}");
1533 }
1534
1535 #[test]
1536 #[should_panic(expected = "read ")]
1537 fn a_listed_file_that_is_gone_fails() {
1538 let dir = frontend("bp-missing");
1541 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1542 }
1543
1544 #[test]
1545 fn a_listed_js_file_is_parsed_as_script() {
1546 let dir = frontend("bp-list-js");
1549 write(
1550 &dir,
1551 "js/style.js",
1552 "el.style.cssText = 'max-width: 320px';\n",
1553 );
1554 Breakpoints::files(&[dir.join("js/style.js")]).run();
1555 }
1556
1557 #[test]
1558 fn the_error_names_the_file_and_line() {
1559 let dir = frontend("bp-message");
1560 write(
1561 &dir,
1562 "css/styles.css",
1563 "body { }\n@media (max-width: 768px) { }\n",
1564 );
1565 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1566 let msg = err
1567 .downcast_ref::<String>()
1568 .expect("panic payload is a String");
1569 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1570 }
1571
1572 #[test]
1573 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1574 let upstream = ".tile-set { gap: 4px; }\n";
1577 let dir = scratch("vocab-upstream");
1578 write(&dir, "css/quasi.css", upstream);
1579 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1580 let err = std::panic::catch_unwind(|| {
1581 Vocabulary::in_frontend(&dir)
1582 .upstream(&[upstream])
1583 .run(&Emit::default());
1584 })
1585 .unwrap_err();
1586 let msg = err
1587 .downcast_ref::<String>()
1588 .expect("panic payload is a String");
1589 assert!(
1590 msg.contains("css/styles.css .tile-set { gap }"),
1591 "got: {msg}"
1592 );
1593 assert!(
1594 !msg.contains("css/quasi.css"),
1595 "the served copy clashed with itself: {msg}"
1596 );
1597
1598 Vocabulary::in_frontend(&dir).run(&Emit::default());
1601 }
1602
1603 #[test]
1604 fn a_rule_restating_a_generated_class_fails_and_names_it() {
1605 let dir = scratch("vocab-clash");
1606 write(
1609 &dir,
1610 "css/styles.css",
1611 "body { color: red; }\n.card { box-shadow: none; }\n",
1612 );
1613 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1614 .unwrap_err();
1615 let msg = err
1616 .downcast_ref::<String>()
1617 .expect("panic payload is a String");
1618 assert!(msg.contains(".card"), "got: {msg}");
1619 assert!(msg.contains("box-shadow"), "got: {msg}");
1620 assert!(msg.contains("css/styles.css"), "got: {msg}");
1621 }
1622
1623 #[test]
1624 fn an_app_class_of_its_own_is_left_alone() {
1625 let dir = scratch("vocab-clean");
1626 write(
1627 &dir,
1628 "css/styles.css",
1629 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1630 );
1631 Vocabulary::in_frontend(&dir).run(&Emit::default());
1632 }
1633
1634 #[test]
1635 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1636 let dir = scratch("vocab-generated");
1637 let opts = Emit::default();
1638 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1639 Vocabulary::in_frontend(&dir)
1642 .generated(&["layout.css"])
1643 .run(&opts);
1644 }
1645
1646 #[test]
1647 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1648 let dir = scratch("vocab-prefix");
1649 let opts = Emit::new().class_prefix("mo-");
1650 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1653 Vocabulary::in_frontend(&dir).run(&opts);
1654
1655 let dir = scratch("vocab-prefix-clash");
1656 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1657 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1658 }
1659
1660 #[test]
1661 fn a_class_shared_without_a_shared_property_is_left_alone() {
1662 let dir = scratch("vocab-additive");
1663 write(
1666 &dir,
1667 "css/styles.css",
1668 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1669 );
1670 Vocabulary::in_frontend(&dir).run(&Emit::default());
1671 }
1672
1673 #[test]
1674 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1675 let dir = scratch("vocab-allowed");
1676 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
1677 Vocabulary::in_frontend(&dir)
1678 .allow(Allow::class("badge", "padding"))
1679 .run(&Emit::default());
1680
1681 let dir = scratch("vocab-allowed-stale");
1684 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1685 let err = std::panic::catch_unwind(|| {
1686 Vocabulary::in_frontend(&dir)
1687 .allow(Allow::class("badge", "padding"))
1688 .run(&Emit::default());
1689 })
1690 .unwrap_err();
1691 let msg = err
1692 .downcast_ref::<String>()
1693 .expect("panic payload is a String");
1694 assert!(msg.contains("no longer collides"), "got: {msg}");
1695 }
1696
1697 #[test]
1698 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1699 let dir = scratch("vocab-element");
1700 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1704 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1705 .unwrap_err();
1706 let msg = err
1707 .downcast_ref::<String>()
1708 .expect("panic payload is a String");
1709 assert!(msg.contains("select {"), "got: {msg}");
1710 assert!(msg.contains(".field"), "got: {msg}");
1711 assert!(msg.contains("box-shadow"), "got: {msg}");
1712 assert!(msg.contains("css/styles.css"), "got: {msg}");
1713 }
1714
1715 #[test]
1716 fn a_universal_reset_after_the_design_system_is_caught() {
1717 let dir = scratch("vocab-universal");
1721 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
1722 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1723 .unwrap_err();
1724 let msg = err
1725 .downcast_ref::<String>()
1726 .expect("panic payload is a String");
1727 assert!(msg.contains("* {"), "got: {msg}");
1728 assert!(msg.contains("padding"), "got: {msg}");
1729 }
1730
1731 #[test]
1732 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
1733 let dir = scratch("vocab-universal-beneath");
1734 write(
1735 &dir,
1736 "css/styles.css",
1737 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
1738 );
1739 Vocabulary::in_frontend(&dir)
1740 .beneath(&["reset"])
1741 .run(&Emit::default());
1742 }
1743
1744 #[test]
1745 fn a_layer_not_named_beneath_is_still_read() {
1746 let dir = scratch("vocab-universal-other-layer");
1747 write(
1748 &dir,
1749 "css/styles.css",
1750 "@layer base {\n * { padding: 0; }\n}\n",
1751 );
1752 let err = std::panic::catch_unwind(|| {
1753 Vocabulary::in_frontend(&dir)
1754 .beneath(&["reset"])
1755 .run(&Emit::default());
1756 })
1757 .unwrap_err();
1758 let msg = err.downcast_ref::<String>().expect("a String payload");
1759 assert!(msg.contains("* {"), "got: {msg}");
1760 }
1761
1762 #[test]
1763 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
1764 let dir = scratch("vocab-universal-box");
1765 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
1766 Vocabulary::in_frontend(&dir).run(&Emit::default());
1767 }
1768
1769 #[test]
1770 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1771 let dir = scratch("vocab-element-handoff");
1772 write(
1777 &dir,
1778 "css/styles.css",
1779 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1780 );
1781 Vocabulary::in_frontend(&dir).run(&Emit::default());
1782 }
1783
1784 #[test]
1785 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1786 let dir = scratch("vocab-element-spoken-for");
1787 write(
1792 &dir,
1793 "css/styles.css",
1794 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
1795 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
1796 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
1797 );
1798 Vocabulary::in_frontend(&dir)
1799 .allow(Allow::class("field", "box-shadow"))
1800 .run(&Emit::default());
1801 }
1802
1803 #[test]
1804 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1805 let dir = scratch("vocab-element-weak-handoff");
1806 write(
1810 &dir,
1811 "css/styles.css",
1812 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1813 );
1814 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1815 .unwrap_err();
1816 let msg = err
1817 .downcast_ref::<String>()
1818 .expect("panic payload is a String");
1819 assert!(msg.contains(".field"), "got: {msg}");
1820
1821 let dir = scratch("vocab-element-strong-handoff");
1823 write(
1824 &dir,
1825 "css/styles.css",
1826 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1827 );
1828 Vocabulary::in_frontend(&dir).run(&Emit::default());
1829 }
1830
1831 #[test]
1832 fn a_scoped_rule_is_not_read_as_an_element_rule() {
1833 let dir = scratch("vocab-element-scoped");
1834 write(
1838 &dir,
1839 "css/styles.css",
1840 ".wizard select { box-shadow: none; }\n",
1841 );
1842 Vocabulary::in_frontend(&dir).run(&Emit::default());
1843 }
1844
1845 #[test]
1846 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1847 let dir = scratch("vocab-element-unpaired");
1848 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1851 Vocabulary::in_frontend(&dir).run(&Emit::default());
1852 }
1853
1854 #[test]
1855 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
1856 let dir = scratch("vocab-cancel-tone");
1861 write(
1862 &dir,
1863 "css/styles.css",
1864 ".button { background: var(--surface-raised); }\n",
1865 );
1866 let err = std::panic::catch_unwind(|| {
1867 Vocabulary::in_frontend(&dir)
1868 .allow(Allow::class("button", "background"))
1869 .run(&Emit::default());
1870 })
1871 .unwrap_err();
1872 let msg = err
1873 .downcast_ref::<String>()
1874 .expect("panic payload is a String");
1875 assert!(
1876 msg.contains(".button { background } cancels"),
1877 "got: {msg}"
1878 );
1879 assert!(
1880 msg.contains(".button[data-tone=\"danger\"] { background }"),
1881 "the message names the variant it cancels: {msg}"
1882 );
1883 assert!(msg.contains("restate"), "and the remedy: {msg}");
1884 }
1885
1886 #[test]
1887 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
1888 let opts = Emit::default();
1891 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
1892 let mut css = String::from(".button { background: var(--surface-raised); }\n");
1893 for (arm, properties) in &variants["button"] {
1894 if properties.contains("background") {
1895 let loose = arm.replace('"', "'").replace(',', " , ");
1896 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
1897 }
1898 }
1899 let dir = scratch("vocab-cancel-restated");
1900 write(&dir, "css/styles.css", &css);
1901 Vocabulary::in_frontend(&dir)
1902 .allow(Allow::class("button", "background"))
1903 .run(&opts);
1904 }
1905
1906 #[test]
1907 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
1908 let dir = scratch("vocab-cancel-scoped");
1911 write(
1912 &dir,
1913 "css/styles.css",
1914 ".toolbar .button { color: var(--content); }\n\
1915 .button.quiet { color: var(--content); }\n",
1916 );
1917 Vocabulary::in_frontend(&dir)
1920 .allow(Allow::class("button", "color"))
1921 .run(&Emit::default());
1922 }
1923
1924 #[test]
1925 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
1926 let dir = scratch("vocab-cancel-own-order");
1930 write(
1931 &dir,
1932 "css/quasi.css",
1933 "@layer reset, quasi, makeover;\n\
1934 @layer quasi {\n .button { cursor: pointer; }\n}\n",
1935 );
1936 Vocabulary::in_frontend(&dir)
1937 .allow(Allow::class("button", "cursor"))
1938 .run(&Emit::default());
1939
1940 let dir = scratch("vocab-cancel-own-order-above");
1942 write(
1943 &dir,
1944 "css/app.css",
1945 "@layer reset, makeover, app;\n\
1946 @layer app {\n .button { cursor: pointer; }\n}\n",
1947 );
1948 let err = std::panic::catch_unwind(|| {
1949 Vocabulary::in_frontend(&dir)
1950 .allow(Allow::class("button", "cursor"))
1951 .run(&Emit::default());
1952 })
1953 .unwrap_err();
1954 let msg = err
1955 .downcast_ref::<String>()
1956 .expect("panic payload is a String");
1957 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
1958 }
1959
1960 #[test]
1961 fn a_handoff_on_the_bare_class_cancels_nothing() {
1962 let dir = scratch("vocab-cancel-handoff");
1963 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
1964 Vocabulary::in_frontend(&dir).run(&Emit::default());
1965 }
1966
1967 #[test]
1968 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1969 let dir = scratch("vocab-element-allowed");
1970 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1971 Vocabulary::in_frontend(&dir)
1972 .allow(Allow::element("select", "field", "box-shadow"))
1973 .run(&Emit::default());
1974
1975 let dir = scratch("vocab-element-allowed-stale");
1978 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1979 let err = std::panic::catch_unwind(|| {
1980 Vocabulary::in_frontend(&dir)
1981 .allow(Allow::element("select", "field", "box-shadow"))
1982 .run(&Emit::default());
1983 })
1984 .unwrap_err();
1985 let msg = err
1986 .downcast_ref::<String>()
1987 .expect("panic payload is a String");
1988 assert!(msg.contains("no longer collides"), "got: {msg}");
1989 }
1990
1991 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
1993 let mut rungs = 0;
1994 let mut ladders = 0;
1995 for ladder in ["min-", "short-"] {
1996 let here = names
1997 .iter()
1998 .filter(|c| {
1999 c.strip_prefix(ladder)
2000 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2001 })
2002 .count();
2003 rungs += here;
2004 ladders += usize::from(here > 0);
2005 }
2006 (rungs, ladders)
2007 }
2008
2009 #[test]
2010 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2011 let dir = scratch("vocab-seal");
2012 let opts = Emit::default();
2013 let names = crate::vocabulary::names(&opts);
2014 let (rungs, ladders) = ladders(&names);
2015 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2016 let dead = names.len() - rungs + ladders;
2019 write(&dir, "index.html", "<div></div>\n");
2020 let markup = [dir.join("index.html")];
2021
2022 check_vocabulary_use(&markup, &opts, dead);
2023 assert!(
2024 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2025 "a vocabulary deader than the seal has to fail"
2026 );
2027 }
2028
2029 #[test]
2030 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2031 let opts = Emit::default();
2034 let names = crate::vocabulary::names(&opts);
2035 let (rungs, ladders) = ladders(&names);
2036 let dead = names.len() - rungs + ladders;
2037
2038 let dir = scratch("vocab-ladder");
2039 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2040 let one_rung = [dir.join("index.html")];
2041 check_vocabulary_use(&one_rung, &opts, dead - 2);
2043
2044 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2046 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2047 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2048 }
2049
2050 #[test]
2051 fn a_class_is_named_whole_on_both_sides() {
2052 assert!(haystack_names("class=\"row\"", "row"));
2053 assert!(!haystack_names("class=\"row-flex\"", "row"));
2054 assert!(!haystack_names("class=\"flex-row\"", "row"));
2055 assert!(haystack_names("el.classList.add('row')", "row"));
2056 }
2057
2058 #[test]
2059 fn range_syntax_and_units_are_read() {
2060 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2061 assert_eq!(
2062 media_widths(" (600px <= width < 840px)"),
2063 [Width::Px(600), Width::Px(840)]
2064 );
2065 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2066 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2067 assert_eq!(
2068 media_widths(" (max-width: 599.98px)"),
2069 [Width::Other("599.98px".into())]
2070 );
2071 assert_eq!(
2072 media_widths(" (width < 50vw)"),
2073 [Width::Other("50vw".into())]
2074 );
2075 assert!(media_widths(" (min-height: 600px)").is_empty());
2076 assert!(media_widths(" (max-device-width: 600px)").is_empty());
2077 assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
2078 }
2079
2080 #[test]
2081 fn every_size_class_condition_passes_its_own_check() {
2082 let dir = frontend("bp-classes");
2083 let mut css = String::new();
2084 for class in SizeClass::all() {
2085 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2086 }
2087 write(&dir, "css/styles.css", &css);
2088 write(
2089 &dir,
2090 "js/shell.js",
2091 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2092 );
2093 Breakpoints::in_frontend(&dir).run();
2094 }
2095
2096 #[test]
2097 #[should_panic(expected = "48em")]
2098 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2099 let dir = frontend("bp-em");
2100 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2101 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2102 }
2103
2104 #[test]
2105 #[should_panic(expected = "(width < 768px)")]
2106 fn a_stale_range_width_fails() {
2107 let dir = frontend("bp-range");
2108 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2109 Breakpoints::in_frontend(&dir).run();
2110 }
2111
2112 #[test]
2113 #[should_panic(expected = "599px")]
2114 fn the_old_inclusive_compact_bound_is_stale() {
2115 let dir = frontend("bp-599");
2116 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2117 Breakpoints::in_frontend(&dir).run();
2118 }
2119
2120 #[test]
2121 fn a_call_in_script_is_not_a_feature() {
2122 assert!(js_widths("const w = Math.max(width, 600);").is_empty());
2123 assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
2124 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2125 }
2126
2127 #[test]
2128 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2129 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2130 let stripped = blank(css, Lang::Css);
2131 assert!(!stripped.contains("768"), "{stripped:?}");
2132 assert!(stripped.contains("a { }"));
2133 assert_eq!(stripped.len(), css.len());
2134 }
2135
2136 #[test]
2137 fn a_comment_opener_inside_a_string_is_text() {
2138 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2140 let src = blank(css, Lang::Css);
2141 assert_eq!(media_conditions(&src).len(), 1);
2142 assert!(src.contains("768"));
2143
2144 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2145 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2146 }
2147
2148 #[test]
2149 fn a_query_in_a_script_comment_is_prose() {
2150 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2151 assert!(js_widths(&blank(js, Lang::Js)).is_empty());
2152 }
2153
2154 #[test]
2155 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2156 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2157 let src = blank(css, Lang::Css);
2158 assert_eq!(media_conditions(&src).len(), 1);
2159 }
2160
2161 #[test]
2162 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2163 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2166 let src = blank(css, Lang::Css);
2167 assert_eq!(src.len(), css.len());
2168 let at = css.find("@layer").unwrap();
2169 assert_eq!(&src[at..at + 6], "@layer");
2170 let dropped = drop_layers(css, &["reset"]);
2171 assert!(!dropped.contains("padding"), "{dropped:?}");
2172 }
2173
2174 #[test]
2175 fn blanking_keeps_lines_and_offsets() {
2176 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2177 let src = blank(css, Lang::Css);
2178 assert_eq!(src.len(), css.len());
2179 assert_eq!(src.lines().count(), css.lines().count());
2180 }
2181
2182 #[test]
2183 fn both_quote_styles_read() {
2184 let want = Density::Touch.media_condition();
2185 for q in ['\'', '"'] {
2186 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2187 let found = touch_density_literals(&src);
2188 assert_eq!(found.len(), 1);
2189 assert_eq!(found[0].1, want);
2190 }
2191 }
2192
2193 #[test]
2194 fn a_file_list_skips_the_sheets_named_generated() {
2195 let dir = frontend("vocab-files");
2196 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2197 write(&dir, "css/app.css", "body { color: red; }\n");
2198 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2199 Vocabulary::files(&files)
2200 .generated(&["layout.css"])
2201 .run(&Emit::default());
2202 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2203 .unwrap_err();
2204 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2205 assert!(msg.contains("box-shadow"), "got: {msg}");
2206 }
2207}