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 find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
698 haystack
699 .get(from..)?
700 .windows(needle.len())
701 .position(|window| window == needle)
702 .map(|at| at + from)
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum Allow<'a> {
713 Class {
715 class: &'a str,
717 property: &'a str,
719 },
720 Element {
723 element: &'a str,
725 class: &'a str,
727 property: &'a str,
729 },
730}
731
732impl<'a> Allow<'a> {
733 pub const fn class(class: &'a str, property: &'a str) -> Self {
735 Self::Class { class, property }
736 }
737
738 pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
740 Self::Element {
741 element,
742 class,
743 property,
744 }
745 }
746}
747
748#[derive(Debug, Clone)]
825#[must_use = "a check does nothing until it is run"]
826pub struct Vocabulary<'a> {
827 source: Source,
828 generated: Vec<&'a str>,
829 upstream: Vec<&'a str>,
830 allowed: Vec<Allow<'a>>,
831 beneath: Vec<&'a str>,
832}
833
834impl<'a> Vocabulary<'a> {
835 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
837 Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
838 }
839
840 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
846 Self::from(Source::files(paths))
847 }
848
849 fn from(source: Source) -> Self {
850 Self {
851 source,
852 generated: Vec::new(),
853 upstream: Vec::new(),
854 allowed: Vec::new(),
855 beneath: Vec::new(),
856 }
857 }
858
859 pub fn generated(mut self, names: &[&'a str]) -> Self {
864 self.generated.extend_from_slice(names);
865 self
866 }
867
868 pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
878 self.upstream.extend_from_slice(sheets);
879 self
880 }
881
882 pub fn beneath(mut self, layers: &[&'a str]) -> Self {
892 self.beneath.extend_from_slice(layers);
893 self
894 }
895
896 pub fn allow(mut self, allow: Allow<'a>) -> Self {
898 self.allowed.push(allow);
899 self
900 }
901
902 pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
904 self.allowed.extend(allows);
905 self
906 }
907
908 pub fn run(&self, opts: &Emit) {
919 let classes: Vec<(&str, &str)> = self
920 .allowed
921 .iter()
922 .filter_map(|allow| match *allow {
923 Allow::Class { class, property } => Some((class, property)),
924 Allow::Element { .. } => None,
925 })
926 .collect();
927 let elements: Vec<(&str, &str, &str)> = self
928 .allowed
929 .iter()
930 .filter_map(|allow| match *allow {
931 Allow::Element {
932 element,
933 class,
934 property,
935 } => Some((element, class, property)),
936 Allow::Class { .. } => None,
937 })
938 .collect();
939 match &self.source {
940 Source::Frontend(frontend) => {
941 let css = frontend.join("css");
942 let files: Vec<PathBuf> = files_with_extension(&css, "css")
943 .into_iter()
944 .filter(|p| {
945 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
946 !self.generated.contains(&name.as_str())
947 })
948 .collect();
949 check_vocabulary_paths(
950 &files,
951 opts,
952 Some(frontend),
953 &self.upstream,
954 &self.beneath,
955 &classes,
956 &elements,
957 );
958 println!("cargo::rerun-if-changed={}", css.display());
960 }
961 Source::Files(paths) => {
962 let files: Vec<PathBuf> = paths
963 .iter()
964 .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
965 .cloned()
966 .collect();
967 check_vocabulary_paths(
968 &files,
969 opts,
970 None,
971 &self.upstream,
972 &self.beneath,
973 &classes,
974 &elements,
975 );
976 }
977 }
978 }
979}
980
981fn check_vocabulary_paths(
983 paths: &[PathBuf],
984 opts: &Emit,
985 root: Option<&Path>,
986 upstream: &[&str],
987 beneath: &[&str],
988 allowed: &[(&str, &str)],
989 allowed_elements: &[(&str, &str, &str)],
990) {
991 let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
992 let mut owned = makeover.clone();
995 for sheet in upstream {
996 for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
997 owned.entry(class).or_default().extend(properties);
998 }
999 }
1000 let mut clashes: Vec<String> = Vec::new();
1001 let mut seen: Vec<(String, String)> = Vec::new();
1002 let mut element_clashes: Vec<String> = Vec::new();
1003 let mut element_seen: Vec<(String, String, String)> = Vec::new();
1004
1005 for path in paths {
1006 println!("cargo::rerun-if-changed={}", path.display());
1007 let raw = std::fs::read_to_string(path)
1008 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1009 let name = match root {
1010 Some(root) => display_name(root, path),
1011 None => path.display().to_string(),
1012 };
1013 let generated = if upstream.contains(&raw.as_str()) {
1016 &makeover
1017 } else {
1018 &owned
1019 };
1020 let raw = drop_layers(&raw, beneath);
1023 let local = crate::vocabulary::declarations_by_class(&raw);
1026 for (class, properties) in &local {
1027 let Some(theirs) = generated.get(class) else {
1028 continue;
1029 };
1030 for property in properties.intersection(theirs) {
1031 seen.push((class.clone(), property.clone()));
1032 if allowed.contains(&(class.as_str(), property.as_str())) {
1033 continue;
1034 }
1035 clashes.push(format!(" {name} .{class} {{ {property} }}"));
1036 }
1037 }
1038
1039 let mentioned = crate::vocabulary::mentions_by_class(&raw);
1045 let by_element = crate::vocabulary::declarations_by_element(&raw);
1046 for (element, properties) in &by_element {
1047 for class in crate::vocabulary::classes_for_element(element, opts) {
1048 let Some(theirs) = generated.get(&class) else {
1049 continue;
1050 };
1051 for (property, rank) in properties {
1052 if !theirs.contains(property) {
1053 continue;
1054 }
1055 let spoken_for = mentioned
1059 .get(&class)
1060 .and_then(|properties| properties.get(property))
1061 .is_some_and(|theirs| theirs >= rank);
1062 if spoken_for {
1063 continue;
1064 }
1065 element_seen.push((element.clone(), class.clone(), property.clone()));
1066 if allowed_elements.contains(&(
1067 element.as_str(),
1068 class.as_str(),
1069 property.as_str(),
1070 )) {
1071 continue;
1072 }
1073 element_clashes.push(format!(
1074 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
1075 ));
1076 }
1077 }
1078 }
1079 }
1080
1081 assert!(
1082 clashes.is_empty(),
1083 "{} hand-written declaration(s) take a property the generated stylesheet, \
1084 or an upstream sheet named to this check, already sets on the same \
1085 class. App CSS wins over @layer makeover, whether by a later layer or \
1086 by being unlayered, so each of these wins over the design system \
1087 silently:\n{}\n\nDelete the declaration, or, if \
1088 it is a deliberate pairing on a different selector arm, add \
1089 (class, property) to this check's allowed list and say why beside it. \
1090 Count the consumers before deciding a divergence is worth keeping.",
1091 clashes.len(),
1092 clashes.join("\n")
1093 );
1094
1095 assert!(
1096 element_clashes.is_empty(),
1097 "{} hand-written element rule(s) take a property the generated \
1098 stylesheet sets on a class that element carries. App CSS wins over \
1099 @layer makeover, whether by a later layer or by being unlayered, so a \
1100 described component rendered on one of these elements loses the \
1101 design system's version of that property silently, which can draw a \
1102 destructive act like an ordinary one:\n{}\n\nHand the \
1103 property back on the arms makeover paints \
1104 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1105 rule so it stops reaching described markup, or add \
1106 (element, class, property) to this check's allowed-elements list and \
1107 say why beside it.",
1108 element_clashes.len(),
1109 element_clashes.join("\n")
1110 );
1111
1112 let stale: Vec<&(&str, &str)> = allowed
1113 .iter()
1114 .filter(|(class, property)| {
1115 !seen.contains(&((*class).to_string(), (*property).to_string()))
1116 })
1117 .collect();
1118 assert!(
1119 stale.is_empty(),
1120 "the allowed list declares {stale:?}, which no longer collides with \
1121 anything. Delete the entries: an exception nobody is using is where the \
1122 next real collision lands and reads as company."
1123 );
1124
1125 let stale: Vec<&(&str, &str, &str)> = allowed_elements
1126 .iter()
1127 .filter(|(element, class, property)| {
1128 !element_seen.contains(&(
1129 (*element).to_string(),
1130 (*class).to_string(),
1131 (*property).to_string(),
1132 ))
1133 })
1134 .collect();
1135 assert!(
1136 stale.is_empty(),
1137 "the allowed-elements list declares {stale:?}, which no longer collides \
1138 with anything. Delete the entries: an exception nobody is using is \
1139 where the next real collision lands and reads as company."
1140 );
1141}
1142
1143fn haystack_names(haystack: &str, class: &str) -> bool {
1147 let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
1148 haystack.match_indices(class).any(|(at, found)| {
1149 haystack[..at].chars().next_back().is_none_or(|c| !part(c))
1150 && haystack[at + found.len()..]
1151 .chars()
1152 .next()
1153 .is_none_or(|c| !part(c))
1154 })
1155}
1156
1157pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1181 let generated = crate::vocabulary::names(opts);
1182 let mut haystack = String::new();
1183 for path in markup {
1184 let path = path.as_ref();
1185 println!("cargo::rerun-if-changed={}", path.display());
1186 haystack.push_str(
1187 &std::fs::read_to_string(path)
1188 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
1189 );
1190 haystack.push('\n');
1191 }
1192
1193 const LADDERS: &[&str] = &["min-", "short-"];
1199 let rung_of = |class: &str| -> Option<&'static str> {
1200 let name = class.strip_prefix(opts.class_prefix)?;
1201 LADDERS.iter().copied().find(|ladder| {
1202 name.strip_prefix(ladder)
1203 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1204 })
1205 };
1206 let mut unused: Vec<String> = generated
1207 .iter()
1208 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1209 .cloned()
1210 .collect();
1211 for ladder in LADDERS {
1212 let rungs: Vec<&String> = generated
1213 .iter()
1214 .filter(|class| rung_of(class) == Some(ladder))
1215 .collect();
1216 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1217 unused.push(format!("{}{ladder}N", opts.class_prefix));
1218 }
1219 }
1220
1221 assert!(
1222 unused.len() <= high_water,
1223 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1224 The vocabulary grew or the markup stopped using it:\n{}",
1225 unused.len(),
1226 generated.len(),
1227 high_water,
1228 unused
1229 .iter()
1230 .map(|c| format!(" .{c}"))
1231 .collect::<Vec<_>>()
1232 .join("\n")
1233 );
1234
1235 if unused.len() < high_water {
1236 println!(
1237 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1238 lower the seal so it cannot grow back",
1239 unused.len(),
1240 high_water
1241 );
1242 }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use std::fmt::Write as _;
1249
1250 fn scratch(name: &str) -> PathBuf {
1251 let dir =
1252 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1253 let _ = std::fs::remove_dir_all(&dir);
1254 std::fs::create_dir_all(&dir).expect("create scratch");
1255 dir
1256 }
1257
1258 fn write(dir: &Path, name: &str, src: &str) {
1259 if let Some(parent) = dir.join(name).parent() {
1260 std::fs::create_dir_all(parent).unwrap();
1261 }
1262 std::fs::write(dir.join(name), src).unwrap();
1263 }
1264
1265 fn declaring() -> String {
1266 format!(
1267 "const {CONST_NAME} = '{}';\n",
1268 Density::Touch.media_condition()
1269 )
1270 }
1271
1272 #[test]
1273 fn the_crates_own_string_passes() {
1274 let dir = scratch("ok");
1275 write(&dir, "touch.js", &declaring());
1276 check_touch_density(&dir);
1277 }
1278
1279 #[test]
1280 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1281 fn a_drifted_literal_fails() {
1282 let dir = scratch("drift");
1283 write(&dir, "touch.js", &declaring());
1284 write(
1285 &dir,
1286 "haptics.js",
1287 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1288 );
1289 check_touch_density(&dir);
1290 }
1291
1292 #[test]
1293 #[should_panic(expected = "device sniff")]
1294 fn the_sniff_cannot_come_back() {
1295 let dir = scratch("sniff");
1296 write(&dir, "touch.js", &declaring());
1297 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1298 check_touch_density(&dir);
1299 }
1300
1301 #[test]
1302 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1303 fn a_frontend_that_states_nothing_fails() {
1304 let dir = scratch("empty");
1305 write(&dir, "app.js", "export const x = 1;\n");
1306 check_touch_density(&dir);
1307 }
1308
1309 #[test]
1310 fn a_use_site_is_not_a_declaration() {
1311 let src =
1315 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1316 assert!(touch_density_literals(&src).is_empty());
1317 }
1318
1319 #[test]
1320 fn nested_files_are_read() {
1321 let dir = scratch("nested");
1324 write(&dir, "touch.js", &declaring());
1325 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1326 let files = js_files(&dir);
1327 assert_eq!(files.len(), 2);
1328 }
1329
1330 #[test]
1331 fn a_non_js_file_is_ignored() {
1332 let dir = scratch("nonjs");
1333 write(&dir, "touch.js", &declaring());
1334 write(&dir, "styles.css", "body { }\n");
1335 assert_eq!(js_files(&dir).len(), 1);
1336 }
1337
1338 fn frontend(name: &str) -> PathBuf {
1339 let dir = scratch(name);
1340 std::fs::create_dir_all(dir.join("css")).unwrap();
1341 std::fs::create_dir_all(dir.join("js")).unwrap();
1342 dir
1343 }
1344
1345 fn boundary() -> u16 {
1347 SizeClass::Medium.min_px()
1348 }
1349
1350 #[test]
1351 fn the_crates_own_boundaries_pass() {
1352 let dir = frontend("bp-ok");
1353 write(
1354 &dir,
1355 "css/styles.css",
1356 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1357 );
1358 Breakpoints::in_frontend(&dir).run();
1359 }
1360
1361 #[test]
1362 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1363 fn a_stale_css_width_fails() {
1364 let dir = frontend("bp-css");
1365 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1366 Breakpoints::in_frontend(&dir).run();
1367 }
1368
1369 #[test]
1370 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1371 fn a_stale_js_width_fails() {
1372 let dir = frontend("bp-js");
1373 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1374 Breakpoints::in_frontend(&dir).run();
1375 }
1376
1377 #[test]
1378 fn a_declared_tuning_width_passes() {
1379 let dir = frontend("bp-tuning");
1380 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1381 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1382 }
1383
1384 #[test]
1385 fn a_width_in_a_comment_is_prose() {
1386 let dir = frontend("bp-comment");
1389 write(
1390 &dir,
1391 "css/styles.css",
1392 "/* was @media (max-width: 768px) until the size classes landed */\n",
1393 );
1394 Breakpoints::in_frontend(&dir).run();
1395 }
1396
1397 #[test]
1398 fn an_unparenthesized_width_is_not_a_breakpoint() {
1399 let dir = frontend("bp-inline");
1403 write(
1404 &dir,
1405 "js/style.js",
1406 "el.style.cssText = 'max-width: 320px; display: block';\n",
1407 );
1408 Breakpoints::in_frontend(&dir).run();
1409 }
1410
1411 #[test]
1412 fn nested_css_is_read() {
1413 let dir = frontend("bp-nested");
1416 write(
1417 &dir,
1418 "css/screens/detail.css",
1419 "@media (max-width: 768px) { }\n",
1420 );
1421 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1422 assert!(found.is_err(), "a nested stylesheet must be scanned");
1423 }
1424
1425 #[test]
1426 fn a_named_list_is_checked() {
1427 let dir = frontend("bp-list");
1428 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1429 let listed = dir.join("css/style.css");
1430 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1431 let msg = err.downcast_ref::<String>().expect("String payload");
1432 assert!(msg.contains("style.css:1"), "got: {msg}");
1433 }
1434
1435 #[test]
1436 #[should_panic(expected = "read ")]
1437 fn a_listed_file_that_is_gone_fails() {
1438 let dir = frontend("bp-missing");
1441 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1442 }
1443
1444 #[test]
1445 fn a_listed_js_file_is_parsed_as_script() {
1446 let dir = frontend("bp-list-js");
1449 write(
1450 &dir,
1451 "js/style.js",
1452 "el.style.cssText = 'max-width: 320px';\n",
1453 );
1454 Breakpoints::files(&[dir.join("js/style.js")]).run();
1455 }
1456
1457 #[test]
1458 fn the_error_names_the_file_and_line() {
1459 let dir = frontend("bp-message");
1460 write(
1461 &dir,
1462 "css/styles.css",
1463 "body { }\n@media (max-width: 768px) { }\n",
1464 );
1465 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1466 let msg = err
1467 .downcast_ref::<String>()
1468 .expect("panic payload is a String");
1469 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1470 }
1471
1472 #[test]
1473 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1474 let upstream = ".tile-set { gap: 4px; }\n";
1477 let dir = scratch("vocab-upstream");
1478 write(&dir, "css/quasi.css", upstream);
1479 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1480 let err = std::panic::catch_unwind(|| {
1481 Vocabulary::in_frontend(&dir)
1482 .upstream(&[upstream])
1483 .run(&Emit::default());
1484 })
1485 .unwrap_err();
1486 let msg = err
1487 .downcast_ref::<String>()
1488 .expect("panic payload is a String");
1489 assert!(
1490 msg.contains("css/styles.css .tile-set { gap }"),
1491 "got: {msg}"
1492 );
1493 assert!(
1494 !msg.contains("css/quasi.css"),
1495 "the served copy clashed with itself: {msg}"
1496 );
1497
1498 Vocabulary::in_frontend(&dir).run(&Emit::default());
1501 }
1502
1503 #[test]
1504 fn a_rule_restating_a_generated_class_fails_and_names_it() {
1505 let dir = scratch("vocab-clash");
1506 write(
1509 &dir,
1510 "css/styles.css",
1511 "body { color: red; }\n.card { box-shadow: none; }\n",
1512 );
1513 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1514 .unwrap_err();
1515 let msg = err
1516 .downcast_ref::<String>()
1517 .expect("panic payload is a String");
1518 assert!(msg.contains(".card"), "got: {msg}");
1519 assert!(msg.contains("box-shadow"), "got: {msg}");
1520 assert!(msg.contains("css/styles.css"), "got: {msg}");
1521 }
1522
1523 #[test]
1524 fn an_app_class_of_its_own_is_left_alone() {
1525 let dir = scratch("vocab-clean");
1526 write(
1527 &dir,
1528 "css/styles.css",
1529 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1530 );
1531 Vocabulary::in_frontend(&dir).run(&Emit::default());
1532 }
1533
1534 #[test]
1535 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1536 let dir = scratch("vocab-generated");
1537 let opts = Emit::default();
1538 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1539 Vocabulary::in_frontend(&dir)
1542 .generated(&["layout.css"])
1543 .run(&opts);
1544 }
1545
1546 #[test]
1547 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1548 let dir = scratch("vocab-prefix");
1549 let opts = Emit::new().class_prefix("mo-");
1550 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1553 Vocabulary::in_frontend(&dir).run(&opts);
1554
1555 let dir = scratch("vocab-prefix-clash");
1556 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1557 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1558 }
1559
1560 #[test]
1561 fn a_class_shared_without_a_shared_property_is_left_alone() {
1562 let dir = scratch("vocab-additive");
1563 write(
1566 &dir,
1567 "css/styles.css",
1568 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1569 );
1570 Vocabulary::in_frontend(&dir).run(&Emit::default());
1571 }
1572
1573 #[test]
1574 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1575 let dir = scratch("vocab-allowed");
1576 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1577 Vocabulary::in_frontend(&dir)
1578 .allow(Allow::class("card", "box-shadow"))
1579 .run(&Emit::default());
1580
1581 let dir = scratch("vocab-allowed-stale");
1584 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1585 let err = std::panic::catch_unwind(|| {
1586 Vocabulary::in_frontend(&dir)
1587 .allow(Allow::class("card", "box-shadow"))
1588 .run(&Emit::default());
1589 })
1590 .unwrap_err();
1591 let msg = err
1592 .downcast_ref::<String>()
1593 .expect("panic payload is a String");
1594 assert!(msg.contains("no longer collides"), "got: {msg}");
1595 }
1596
1597 #[test]
1598 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1599 let dir = scratch("vocab-element");
1600 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1604 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1605 .unwrap_err();
1606 let msg = err
1607 .downcast_ref::<String>()
1608 .expect("panic payload is a String");
1609 assert!(msg.contains("select {"), "got: {msg}");
1610 assert!(msg.contains(".field"), "got: {msg}");
1611 assert!(msg.contains("box-shadow"), "got: {msg}");
1612 assert!(msg.contains("css/styles.css"), "got: {msg}");
1613 }
1614
1615 #[test]
1616 fn a_universal_reset_after_the_design_system_is_caught() {
1617 let dir = scratch("vocab-universal");
1621 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
1622 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1623 .unwrap_err();
1624 let msg = err
1625 .downcast_ref::<String>()
1626 .expect("panic payload is a String");
1627 assert!(msg.contains("* {"), "got: {msg}");
1628 assert!(msg.contains("padding"), "got: {msg}");
1629 }
1630
1631 #[test]
1632 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
1633 let dir = scratch("vocab-universal-beneath");
1634 write(
1635 &dir,
1636 "css/styles.css",
1637 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
1638 );
1639 Vocabulary::in_frontend(&dir)
1640 .beneath(&["reset"])
1641 .run(&Emit::default());
1642 }
1643
1644 #[test]
1645 fn a_layer_not_named_beneath_is_still_read() {
1646 let dir = scratch("vocab-universal-other-layer");
1647 write(
1648 &dir,
1649 "css/styles.css",
1650 "@layer base {\n * { padding: 0; }\n}\n",
1651 );
1652 let err = std::panic::catch_unwind(|| {
1653 Vocabulary::in_frontend(&dir)
1654 .beneath(&["reset"])
1655 .run(&Emit::default());
1656 })
1657 .unwrap_err();
1658 let msg = err.downcast_ref::<String>().expect("a String payload");
1659 assert!(msg.contains("* {"), "got: {msg}");
1660 }
1661
1662 #[test]
1663 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
1664 let dir = scratch("vocab-universal-box");
1665 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
1666 Vocabulary::in_frontend(&dir).run(&Emit::default());
1667 }
1668
1669 #[test]
1670 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1671 let dir = scratch("vocab-element-handoff");
1672 write(
1677 &dir,
1678 "css/styles.css",
1679 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1680 );
1681 Vocabulary::in_frontend(&dir).run(&Emit::default());
1682 }
1683
1684 #[test]
1685 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1686 let dir = scratch("vocab-element-spoken-for");
1687 write(
1692 &dir,
1693 "css/styles.css",
1694 "select { box-shadow: none; }\n.field { box-shadow: none; }\n",
1695 );
1696 Vocabulary::in_frontend(&dir)
1697 .allow(Allow::class("field", "box-shadow"))
1698 .run(&Emit::default());
1699 }
1700
1701 #[test]
1702 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1703 let dir = scratch("vocab-element-weak-handoff");
1704 write(
1708 &dir,
1709 "css/styles.css",
1710 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1711 );
1712 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1713 .unwrap_err();
1714 let msg = err
1715 .downcast_ref::<String>()
1716 .expect("panic payload is a String");
1717 assert!(msg.contains(".field"), "got: {msg}");
1718
1719 let dir = scratch("vocab-element-strong-handoff");
1721 write(
1722 &dir,
1723 "css/styles.css",
1724 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1725 );
1726 Vocabulary::in_frontend(&dir).run(&Emit::default());
1727 }
1728
1729 #[test]
1730 fn a_scoped_rule_is_not_read_as_an_element_rule() {
1731 let dir = scratch("vocab-element-scoped");
1732 write(
1736 &dir,
1737 "css/styles.css",
1738 ".wizard select { box-shadow: none; }\n",
1739 );
1740 Vocabulary::in_frontend(&dir).run(&Emit::default());
1741 }
1742
1743 #[test]
1744 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1745 let dir = scratch("vocab-element-unpaired");
1746 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1749 Vocabulary::in_frontend(&dir).run(&Emit::default());
1750 }
1751
1752 #[test]
1753 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1754 let dir = scratch("vocab-element-allowed");
1755 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1756 Vocabulary::in_frontend(&dir)
1757 .allow(Allow::element("select", "field", "box-shadow"))
1758 .run(&Emit::default());
1759
1760 let dir = scratch("vocab-element-allowed-stale");
1763 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1764 let err = std::panic::catch_unwind(|| {
1765 Vocabulary::in_frontend(&dir)
1766 .allow(Allow::element("select", "field", "box-shadow"))
1767 .run(&Emit::default());
1768 })
1769 .unwrap_err();
1770 let msg = err
1771 .downcast_ref::<String>()
1772 .expect("panic payload is a String");
1773 assert!(msg.contains("no longer collides"), "got: {msg}");
1774 }
1775
1776 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
1778 let mut rungs = 0;
1779 let mut ladders = 0;
1780 for ladder in ["min-", "short-"] {
1781 let here = names
1782 .iter()
1783 .filter(|c| {
1784 c.strip_prefix(ladder)
1785 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1786 })
1787 .count();
1788 rungs += here;
1789 ladders += usize::from(here > 0);
1790 }
1791 (rungs, ladders)
1792 }
1793
1794 #[test]
1795 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
1796 let dir = scratch("vocab-seal");
1797 let opts = Emit::default();
1798 let names = crate::vocabulary::names(&opts);
1799 let (rungs, ladders) = ladders(&names);
1800 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
1801 let dead = names.len() - rungs + ladders;
1804 write(&dir, "index.html", "<div></div>\n");
1805 let markup = [dir.join("index.html")];
1806
1807 check_vocabulary_use(&markup, &opts, dead);
1808 assert!(
1809 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
1810 "a vocabulary deader than the seal has to fail"
1811 );
1812 }
1813
1814 #[test]
1815 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
1816 let opts = Emit::default();
1819 let names = crate::vocabulary::names(&opts);
1820 let (rungs, ladders) = ladders(&names);
1821 let dead = names.len() - rungs + ladders;
1822
1823 let dir = scratch("vocab-ladder");
1824 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
1825 let one_rung = [dir.join("index.html")];
1826 check_vocabulary_use(&one_rung, &opts, dead - 2);
1828
1829 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
1831 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
1832 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
1833 }
1834
1835 #[test]
1836 fn a_class_is_named_whole_on_both_sides() {
1837 assert!(haystack_names("class=\"row\"", "row"));
1838 assert!(!haystack_names("class=\"row-flex\"", "row"));
1839 assert!(!haystack_names("class=\"flex-row\"", "row"));
1840 assert!(haystack_names("el.classList.add('row')", "row"));
1841 }
1842
1843 #[test]
1844 fn range_syntax_and_units_are_read() {
1845 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
1846 assert_eq!(
1847 media_widths(" (600px <= width < 840px)"),
1848 [Width::Px(600), Width::Px(840)]
1849 );
1850 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
1851 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
1852 assert_eq!(
1853 media_widths(" (max-width: 599.98px)"),
1854 [Width::Other("599.98px".into())]
1855 );
1856 assert_eq!(
1857 media_widths(" (width < 50vw)"),
1858 [Width::Other("50vw".into())]
1859 );
1860 assert!(media_widths(" (min-height: 600px)").is_empty());
1861 assert!(media_widths(" (max-device-width: 600px)").is_empty());
1862 assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
1863 }
1864
1865 #[test]
1866 fn every_size_class_condition_passes_its_own_check() {
1867 let dir = frontend("bp-classes");
1868 let mut css = String::new();
1869 for class in SizeClass::all() {
1870 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
1871 }
1872 write(&dir, "css/styles.css", &css);
1873 write(
1874 &dir,
1875 "js/shell.js",
1876 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
1877 );
1878 Breakpoints::in_frontend(&dir).run();
1879 }
1880
1881 #[test]
1882 #[should_panic(expected = "48em")]
1883 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
1884 let dir = frontend("bp-em");
1885 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
1886 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
1887 }
1888
1889 #[test]
1890 #[should_panic(expected = "(width < 768px)")]
1891 fn a_stale_range_width_fails() {
1892 let dir = frontend("bp-range");
1893 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
1894 Breakpoints::in_frontend(&dir).run();
1895 }
1896
1897 #[test]
1898 #[should_panic(expected = "599px")]
1899 fn the_old_inclusive_compact_bound_is_stale() {
1900 let dir = frontend("bp-599");
1901 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
1902 Breakpoints::in_frontend(&dir).run();
1903 }
1904
1905 #[test]
1906 fn a_call_in_script_is_not_a_feature() {
1907 assert!(js_widths("const w = Math.max(width, 600);").is_empty());
1908 assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
1909 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
1910 }
1911
1912 #[test]
1913 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
1914 let css = "/*/ @media (max-width: 768px) { } */ a { }";
1915 let stripped = blank(css, Lang::Css);
1916 assert!(!stripped.contains("768"), "{stripped:?}");
1917 assert!(stripped.contains("a { }"));
1918 assert_eq!(stripped.len(), css.len());
1919 }
1920
1921 #[test]
1922 fn a_comment_opener_inside_a_string_is_text() {
1923 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
1925 let src = blank(css, Lang::Css);
1926 assert_eq!(media_conditions(&src).len(), 1);
1927 assert!(src.contains("768"));
1928
1929 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
1930 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
1931 }
1932
1933 #[test]
1934 fn a_query_in_a_script_comment_is_prose() {
1935 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
1936 assert!(js_widths(&blank(js, Lang::Js)).is_empty());
1937 }
1938
1939 #[test]
1940 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
1941 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
1942 let src = blank(css, Lang::Css);
1943 assert_eq!(media_conditions(&src).len(), 1);
1944 }
1945
1946 #[test]
1947 fn blanking_keeps_byte_offsets_through_multibyte_text() {
1948 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
1951 let src = blank(css, Lang::Css);
1952 assert_eq!(src.len(), css.len());
1953 let at = css.find("@layer").unwrap();
1954 assert_eq!(&src[at..at + 6], "@layer");
1955 let dropped = drop_layers(css, &["reset"]);
1956 assert!(!dropped.contains("padding"), "{dropped:?}");
1957 }
1958
1959 #[test]
1960 fn blanking_keeps_lines_and_offsets() {
1961 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
1962 let src = blank(css, Lang::Css);
1963 assert_eq!(src.len(), css.len());
1964 assert_eq!(src.lines().count(), css.lines().count());
1965 }
1966
1967 #[test]
1968 fn both_quote_styles_read() {
1969 let want = Density::Touch.media_condition();
1970 for q in ['\'', '"'] {
1971 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
1972 let found = touch_density_literals(&src);
1973 assert_eq!(found.len(), 1);
1974 assert_eq!(found[0].1, want);
1975 }
1976 }
1977
1978 #[test]
1979 fn a_file_list_skips_the_sheets_named_generated() {
1980 let dir = frontend("vocab-files");
1981 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
1982 write(&dir, "css/app.css", "body { color: red; }\n");
1983 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
1984 Vocabulary::files(&files)
1985 .generated(&["layout.css"])
1986 .run(&Emit::default());
1987 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
1988 .unwrap_err();
1989 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
1990 assert!(msg.contains("box-shadow"), "got: {msg}");
1991 }
1992}