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 raw = std::fs::read_to_string(path)
59 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
60 let src = blank(&raw, Lang::Js);
64 let name = path
65 .strip_prefix(js_dir)
66 .unwrap_or(path)
67 .display()
68 .to_string();
69
70 for (offset, literal) in touch_density_literals(&src) {
71 found += 1;
72 if literal != want {
73 wrong.push(format!(
74 " {name}:{} {CONST_NAME} = '{literal}'",
75 line_of(&src, offset)
76 ));
77 }
78 }
79
80 for needle in SNIFFS {
81 if let Some(offset) = src.find(needle) {
82 wrong.push(format!(
83 " {name}:{} {needle} -- device sniff, not a density question",
84 line_of(&src, offset)
85 ));
86 }
87 }
88 }
89
90 assert!(
91 found > 0,
92 "no {CONST_NAME} literal found under {}.\n\n\
93 A frontend that asks whether it is being touched states\n\
94 makeover_geometry::Density::Touch's media condition in a const of that\n\
95 name, and this check exists to keep every copy equal to it. If the\n\
96 const was renamed, rename it back rather than dropping the check; if\n\
97 this frontend genuinely asks no density question, drop the call.",
98 js_dir.display()
99 );
100
101 assert!(
102 wrong.is_empty(),
103 "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
104 Density::Touch.media_condition() is: {want}\n\n\
105 Wrong:\n{}\n\n\
106 Fix the JS to state the crate's string. Never widen it to catch a\n\
107 device the query misses: density is what is pointing at the screen,\n\
108 and a laptop with a touchscreen and a mouse is a pointer device.",
109 wrong.join("\n")
110 );
111
112 for path in &files {
113 println!("cargo:rerun-if-changed={}", path.display());
114 }
115 println!("cargo:rerun-if-changed={}", js_dir.display());
119}
120
121fn js_files(dir: &Path) -> Vec<PathBuf> {
123 files_with_extension(dir, "js")
124}
125
126fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
133 let mut out = Vec::new();
134 let mut stack = vec![dir.to_path_buf()];
135 while let Some(d) = stack.pop() {
136 for entry in std::fs::read_dir(&d)
137 .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
138 .flatten()
139 {
140 let path = entry.path();
141 if path.is_dir() {
142 stack.push(path);
143 } else if path.extension().is_some_and(|x| x == ext) {
144 out.push(path);
145 }
146 }
147 }
148 out.sort();
149 out
150}
151
152fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
159 let mut out = Vec::new();
160 let mut at = 0;
161 while let Some(i) = src[at..].find(CONST_NAME) {
162 let start = at + i;
163 at = start + CONST_NAME.len();
164 if start > 0 && is_ident_byte(src.as_bytes()[start - 1]) {
167 continue;
168 }
169 if src.as_bytes().get(at).copied().is_some_and(is_ident_byte) {
170 continue;
171 }
172 let rest = &src[at..];
174 let lead = rest.len() - rest.trim_start().len();
175 let rest = &rest[lead..];
176 if !rest.starts_with('=') {
177 continue;
178 }
179 let rest = &rest[1..];
182 let gap = rest.len() - rest.trim_start().len();
183 let rest = &rest[gap..];
184 let Some(quote @ ('\'' | '"' | '`')) = rest.chars().next() else {
185 continue;
186 };
187 let body = at + lead + 1 + gap + quote.len_utf8();
188 if let Some(j) = src[body..].find(quote) {
189 out.push((start, &src[body..body + j]));
190 at = body + j + 1;
191 }
192 }
193 out
194}
195
196fn is_ident_byte(b: u8) -> bool {
199 b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
200}
201
202fn line_of(src: &str, offset: usize) -> usize {
203 src[..offset].matches('\n').count() + 1
204}
205
206#[derive(Debug, Clone)]
208enum Source {
209 Frontend(PathBuf),
211 Files(Vec<PathBuf>),
213}
214
215impl Source {
216 fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
217 Self::Files(paths.iter().map(|p| p.as_ref().to_path_buf()).collect())
218 }
219}
220
221#[derive(Debug, Clone)]
245#[must_use = "a check does nothing until it is run"]
246pub struct Breakpoints {
247 source: Source,
248 tuning: Vec<u16>,
249}
250
251impl Breakpoints {
252 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
255 Self {
256 source: Source::Frontend(frontend.as_ref().to_path_buf()),
257 tuning: Vec::new(),
258 }
259 }
260
261 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
271 Self {
272 source: Source::files(paths),
273 tuning: Vec::new(),
274 }
275 }
276
277 pub fn tuning(mut self, widths: &[u16]) -> Self {
287 self.tuning.extend_from_slice(widths);
288 self
289 }
290
291 pub fn run(&self) {
301 match &self.source {
302 Source::Frontend(frontend) => {
303 let mut files = files_with_extension(&frontend.join("css"), "css");
304 let js = frontend.join("js");
307 if js.is_dir() {
308 files.extend(js_files(&js));
309 }
310 check_paths(&files, &self.tuning, Some(frontend));
311 println!("cargo:rerun-if-changed={}", frontend.join("css").display());
313 println!("cargo:rerun-if-changed={}", js.display());
314 }
315 Source::Files(paths) => check_paths(paths, &self.tuning, None),
316 }
317 }
318}
319
320fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
322 let allowed = allowed_widths(tuning_widths);
323 let edges = size_class_widths();
324 let mut stale: Vec<String> = Vec::new();
325
326 for path in paths {
327 let raw = std::fs::read_to_string(path)
328 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
329 let name = match root {
330 Some(root) => display_name(root, path),
331 None => path.display().to_string(),
332 };
333
334 if path.extension().is_some_and(|x| x == "js") {
335 for (offset, bound) in js_widths(&blank(&raw, Lang::Js)) {
337 if !bound.is_allowed(&allowed, &edges) {
338 stale.push(format!(
339 " {name}:{} ({})",
340 line_of(&raw, offset),
341 bound.width
342 ));
343 }
344 }
345 continue;
346 }
347
348 let src = blank(&raw, Lang::Css);
351 for (offset, condition) in media_conditions(&src) {
352 for bound in media_bounds(condition) {
353 if !bound.is_allowed(&allowed, &edges) {
354 stale.push(format!(
355 " {name}:{} @media{condition} ({})",
356 line_of(&src, offset),
357 bound.width
358 ));
359 }
360 }
361 }
362 }
363
364 assert!(
365 stale.is_empty(),
366 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
367 Allowed: {allowed:?}\n\
368 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
369 Stale:\n{}\n\n\
370 Size classes are written in range syntax, `(width < 600px)`, because\n\
371 `(max-width: 599px)` leaves 599.5px in no class. If a size class moved,\n\
372 update these to match. If one of these is a new\n\
373 tuning width inside the wide shell rather than a shell boundary, add it\n\
374 to the caller's tuning list with a note saying what it tunes.\n\n\
375 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
376 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
377 clamp(). A threshold is for what appears and disappears.",
378 allowed
379 .iter()
380 .filter(|px| !tuning_widths.contains(px))
381 .collect::<Vec<_>>(),
382 stale.join("\n")
383 );
384
385 for path in paths {
386 println!("cargo:rerun-if-changed={}", path.display());
387 }
388}
389
390fn display_name(frontend: &Path, path: &Path) -> String {
392 path.strip_prefix(frontend)
393 .unwrap_or(path)
394 .display()
395 .to_string()
396}
397
398fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
404 let mut widths = size_class_widths();
405 widths.extend_from_slice(tuning_widths);
406 widths.sort_unstable();
407 widths.dedup();
408 widths
409}
410
411fn size_class_widths() -> Vec<u16> {
416 let mut widths: Vec<u16> = SizeClass::all()
417 .iter()
418 .flat_map(|c| media_widths(&c.media_condition()))
419 .map(|width| match width {
420 Width::Px(px) => px,
421 Width::Other(raw) => {
422 panic!("makeover-geometry wrote a size-class width this check cannot read: {raw}")
423 }
424 })
425 .collect();
426 widths.sort_unstable();
427 widths.dedup();
428 widths
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
433enum Width {
434 Px(u16),
437 Other(String),
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
445struct Bound {
446 width: Width,
447 direction: Direction,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453enum Direction {
454 Edge,
457 Straddles,
460}
461
462impl Bound {
463 fn is_allowed(&self, allowed: &[u16], edges: &[u16]) -> bool {
468 match &self.width {
469 Width::Px(px) => {
470 allowed.contains(px)
471 && !(edges.contains(px) && self.direction == Direction::Straddles)
472 }
473 Width::Other(_) => false,
474 }
475 }
476}
477
478impl std::fmt::Display for Width {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 match self {
481 Self::Px(px) => write!(f, "{px}px"),
482 Self::Other(raw) => f.write_str(raw),
483 }
484 }
485}
486
487fn media_widths(condition: &str) -> Vec<Width> {
489 media_bounds(condition)
490 .into_iter()
491 .map(|bound| bound.width)
492 .collect()
493}
494
495fn media_bounds(condition: &str) -> Vec<Bound> {
505 width_groups(condition)
506 .into_iter()
507 .flat_map(|(_, group)| lengths(group))
508 .collect()
509}
510
511fn width_groups(src: &str) -> Vec<(usize, &str)> {
515 let mut out: Vec<(usize, &str)> = Vec::new();
516 let mut stack: Vec<usize> = Vec::new();
517 for (i, c) in src.char_indices() {
518 match c {
519 '(' => stack.push(i),
520 ')' => {
521 if let Some(start) = stack.pop() {
522 let inner = &src[start + 1..i];
523 let encloses_read = out.iter().any(|&(inner_start, _)| inner_start > start);
528 if names_width(inner) && !encloses_read {
529 out.push((start, inner));
530 }
531 }
532 }
533 _ => {}
534 }
535 }
536 out
537}
538
539fn names_width(inner: &str) -> bool {
543 width_token_at(inner).is_some()
544}
545
546fn width_token_at(inner: &str) -> Option<usize> {
550 inner.match_indices("width").find_map(|(at, _)| {
551 let before = &inner[..at];
552 let prefix_ok = before.is_empty()
553 || before.ends_with("min-")
554 || before.ends_with("max-")
555 || before
556 .chars()
557 .next_back()
558 .is_some_and(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_');
559 let after = inner[at + "width".len()..].chars().next();
560 (prefix_ok && after.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
561 .then_some(at)
562 })
563}
564
565fn lengths(group: &str) -> Vec<Bound> {
568 let bytes = group.as_bytes();
569 let width_at = width_token_at(group);
570 let mut out = Vec::new();
571 let mut i = 0;
572 while i < bytes.len() {
573 let prev_ok = i == 0
576 || !(bytes[i - 1].is_ascii_alphanumeric()
577 || bytes[i - 1] == b'-'
578 || bytes[i - 1] == b'.');
579 let starts_number = prev_ok
580 && (bytes[i].is_ascii_digit()
581 || (bytes[i] == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)));
582 if !starts_number {
583 i += 1;
584 continue;
585 }
586 let start = i;
587 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
588 i += 1;
589 }
590 let number = &group[start..i];
591 let unit_start = i;
592 while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
593 i += 1;
594 }
595 let unit = &group[unit_start..i];
596 if unit.is_empty() {
597 continue;
598 }
599 let raw = &group[start..i];
600 let scale = match unit {
601 "px" => Some(1.0),
602 "em" | "rem" => Some(16.0),
603 _ => None,
604 };
605 let px = scale.and_then(|scale| number.parse::<f64>().ok().map(|n| n * scale));
606 let width = match px {
607 Some(px) if px.fract() == 0.0 && px >= 0.0 && px <= f64::from(u16::MAX) => {
608 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
610 Width::Px(px as u16)
611 }
612 _ => Width::Other(raw.to_string()),
613 };
614 out.push(Bound {
615 width,
616 direction: direction_of(group, start, i, width_at),
617 });
618 }
619 out
620}
621
622fn direction_of(group: &str, start: usize, end: usize, width_at: Option<usize>) -> Direction {
631 let Some(width_at) = width_at else {
632 return Direction::Edge;
633 };
634 let width_end = width_at + "width".len();
635 if start >= width_end {
636 let op = &group[width_end..start];
638 if op.contains("<=") {
639 Direction::Straddles
640 } else if op.contains(">=") || op.contains('<') {
641 Direction::Edge
642 } else if op.contains('>') {
643 Direction::Straddles
644 } else if group[..width_at].ends_with("min-") {
645 Direction::Edge
646 } else {
647 Direction::Straddles
649 }
650 } else {
651 let op = &group[end..width_at];
653 if op.contains("<=") || op.contains('>') {
654 Direction::Edge
655 } else {
656 Direction::Straddles
657 }
658 }
659}
660
661fn media_conditions(css: &str) -> Vec<(usize, &str)> {
669 let lower = css.to_ascii_lowercase();
670 let mut out = Vec::new();
671 let mut at = 0;
672 while let Some(rel) = lower[at..].find('@') {
673 let start = at + rel;
674 let kw_start = start + 1;
675 let kw_end = css[kw_start..]
676 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
677 .map_or(css.len(), |i| kw_start + i);
678 let terminator = match &lower[kw_start..kw_end] {
679 "media" | "container" => '{',
680 "custom-media" | "import" => ';',
681 _ => {
682 at = start + 1;
683 continue;
684 }
685 };
686 match css[kw_end..].find(terminator) {
687 Some(j) => {
688 out.push((start, &css[kw_end..kw_end + j]));
689 at = kw_end + j + 1;
690 }
691 None => break,
692 }
693 }
694 out
695}
696
697fn js_widths(src: &str) -> Vec<(usize, Bound)> {
705 width_groups(src)
706 .into_iter()
707 .filter(|(_, group)| is_feature(group))
708 .flat_map(|(offset, group)| lengths(group).into_iter().map(move |b| (offset, b)))
709 .collect()
710}
711
712fn is_feature(group: &str) -> bool {
716 let group = group.trim();
717 let colon = ["min-width", "max-width", "width"].iter().any(|name| {
718 group
719 .strip_prefix(name)
720 .is_some_and(|rest| rest.trim_start().starts_with(':'))
721 });
722 colon || group.contains(['<', '>'])
723}
724
725#[derive(Clone, Copy, PartialEq, Eq)]
727enum Lang {
728 Css,
731 Js,
735}
736
737fn blank(src: &str, lang: Lang) -> String {
745 #[derive(Clone, Copy)]
746 enum State {
747 Code,
748 Block,
749 Line,
750 Quoted(char),
751 Regex,
752 RawUrl,
753 }
754 let blank_into = |out: &mut String, c: char| {
757 if c == '\n' {
758 out.push('\n');
759 } else {
760 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
761 }
762 };
763 let chars: Vec<char> = src.chars().collect();
764 let mut out = String::with_capacity(src.len());
765 let mut state = State::Code;
766 let mut prev: Option<char> = None;
769 let mut in_class = false;
770 let mut i = 0;
771 while i < chars.len() {
772 let c = chars[i];
773 match state {
774 State::Code => {
775 let at_url = lang == Lang::Css
778 && (c == 'u' || c == 'U')
779 && chars.get(i + 1).is_some_and(|x| *x == 'r' || *x == 'R')
780 && chars.get(i + 2).is_some_and(|x| *x == 'l' || *x == 'L')
781 && chars.get(i + 3) == Some(&'(')
782 && !(i > 0
783 && (chars[i - 1].is_ascii_alphanumeric()
784 || chars[i - 1] == '_'
785 || chars[i - 1] == '-'));
786 if at_url {
787 out.extend(chars[i..i + 4].iter());
788 i += 4;
789 while i < chars.len() && chars[i].is_ascii_whitespace() {
790 out.push(chars[i]);
791 i += 1;
792 }
793 match chars.get(i) {
794 Some(&q) if q == '"' || q == '\'' => {
795 out.push(q);
796 i += 1;
797 state = State::Quoted(q);
798 }
799 _ => state = State::RawUrl,
800 }
801 continue;
802 }
803 match c {
804 '/' if chars.get(i + 1) == Some(&'*') => {
805 out.push_str(" ");
806 i += 2;
807 state = State::Block;
808 }
809 '/' if lang == Lang::Js && chars.get(i + 1) == Some(&'/') => {
810 out.push_str(" ");
811 i += 2;
812 state = State::Line;
813 }
814 '/' if lang == Lang::Js && regex_can_follow(prev) => {
815 out.push('/');
816 i += 1;
817 in_class = false;
818 state = State::Regex;
819 }
820 '\\' => {
821 out.push('\\');
824 i += 1;
825 if let Some(&next) = chars.get(i) {
826 out.push(next);
827 i += 1;
828 }
829 }
830 '"' | '\'' | '`' if lang == Lang::Js || c != '`' => {
831 out.push(c);
832 i += 1;
833 state = State::Quoted(c);
834 }
835 _ => {
836 out.push(c);
837 if !c.is_whitespace() {
838 prev = Some(c);
839 }
840 i += 1;
841 }
842 }
843 }
844 State::Regex => {
845 if c == '\\' {
846 out.push(c);
847 i += 1;
848 if let Some(&next) = chars.get(i) {
849 out.push(next);
850 i += 1;
851 }
852 } else if c == '[' && !in_class {
853 out.push(c);
854 in_class = true;
855 i += 1;
856 } else if c == '/' && !in_class {
857 out.push(c);
858 i += 1;
859 prev = Some('/');
860 state = State::Code;
861 } else if c == '\n' {
862 out.push('\n');
864 i += 1;
865 state = State::Code;
866 } else {
867 if c == ']' {
868 in_class = false;
869 }
870 out.push(c);
871 i += 1;
872 }
873 }
874 State::RawUrl => {
875 if c == '\\' {
876 out.push(c);
877 i += 1;
878 if let Some(&next) = chars.get(i) {
879 out.push(next);
880 i += 1;
881 }
882 } else {
883 out.push(c);
884 i += 1;
885 if c == ')' || c == '\n' {
886 if c == ')' {
887 prev = Some(')');
888 }
889 state = State::Code;
890 }
891 }
892 }
893 State::Block => {
894 if c == '*' && chars.get(i + 1) == Some(&'/') {
895 out.push_str(" ");
896 i += 2;
897 state = State::Code;
898 } else {
899 blank_into(&mut out, c);
900 i += 1;
901 }
902 }
903 State::Line => {
904 blank_into(&mut out, c);
905 i += 1;
906 if c == '\n' {
907 state = State::Code;
908 }
909 }
910 State::Quoted(quote) => {
911 let keep = |out: &mut String, c: char| {
912 if lang == Lang::Js {
913 out.push(c);
914 } else {
915 blank_into(out, c);
916 }
917 };
918 if c == '\\' {
919 keep(&mut out, c);
920 i += 1;
921 if let Some(&next) = chars.get(i) {
922 keep(&mut out, next);
923 i += 1;
924 }
925 } else if c == quote {
926 out.push(c);
927 i += 1;
928 prev = Some(c);
929 state = State::Code;
930 } else if lang == Lang::Css && c == '\n' {
931 blank_into(&mut out, c);
934 i += 1;
935 state = State::Code;
936 } else {
937 keep(&mut out, c);
938 i += 1;
939 }
940 }
941 }
942 }
943 out
944}
945
946fn regex_can_follow(prev: Option<char>) -> bool {
950 match prev {
951 None => true,
952 Some(c) => {
953 !(c.is_alphanumeric()
954 || c == '_'
955 || c == '$'
956 || c == ')'
957 || c == ']'
958 || c == '}'
959 || c == '"'
960 || c == '\''
961 || c == '`')
962 }
963 }
964}
965
966fn drop_layers(css: &str, names: &[&str]) -> String {
979 if names.is_empty() {
980 return css.to_owned();
981 }
982 let scan = blank(css, Lang::Css);
983 let scan = scan.as_bytes();
984 let mut out = css.as_bytes().to_vec();
985 let mut at = 0;
986 while let Some(found) = find_at_layer(scan, at) {
987 let mut i = found + b"@layer".len();
988 while i < scan.len() && scan[i].is_ascii_whitespace() {
989 i += 1;
990 }
991 let start = i;
992 while i < scan.len()
993 && (scan[i].is_ascii_alphanumeric() || matches!(scan[i], b'-' | b'_' | b'.'))
994 {
995 i += 1;
996 }
997 let name = &css[start..i];
998 while i < scan.len() && scan[i].is_ascii_whitespace() {
999 i += 1;
1000 }
1001 if i >= scan.len() || scan[i] != b'{' || !layer_is_beneath(name, names) {
1002 at = found + 1;
1003 continue;
1004 }
1005 let mut depth = 0usize;
1006 let mut end = scan.len();
1007 let mut balanced = false;
1008 for (k, &byte) in scan.iter().enumerate().skip(i) {
1009 match byte {
1010 b'{' => depth += 1,
1011 b'}' => {
1012 depth -= 1;
1013 if depth == 0 {
1014 end = k + 1;
1015 balanced = true;
1016 break;
1017 }
1018 }
1019 _ => {}
1020 }
1021 }
1022 if balanced {
1023 blank_dropped_layer(&mut out, scan, found, i, end - 1);
1024 } else {
1025 blank_span(&mut out, found, end);
1026 }
1027 at = end;
1028 }
1029 String::from_utf8(out).unwrap_or_else(|_| css.to_owned())
1030}
1031
1032fn layer_is_beneath(name: &str, names: &[&str]) -> bool {
1036 names.iter().any(|beneath| {
1037 name == *beneath
1038 || name
1039 .strip_prefix(beneath)
1040 .is_some_and(|rest| rest.starts_with('.'))
1041 })
1042}
1043
1044fn find_at_layer(haystack: &[u8], from: usize) -> Option<usize> {
1047 let needle = b"@layer";
1048 let mut i = from;
1049 while i + needle.len() <= haystack.len() {
1050 if haystack[i..i + needle.len()].eq_ignore_ascii_case(needle) {
1051 return Some(i);
1052 }
1053 i += 1;
1054 }
1055 None
1056}
1057
1058fn blank_span(out: &mut [u8], start: usize, end: usize) {
1060 for byte in &mut out[start..end] {
1061 if *byte != b'\n' {
1062 *byte = b' ';
1063 }
1064 }
1065}
1066
1067fn blank_dropped_layer(out: &mut [u8], scan: &[u8], head: usize, open: usize, close: usize) {
1075 blank_span(out, head, open + 1);
1076 blank_dropped_content(out, scan, open + 1, close);
1077 blank_span(out, close, close + 1);
1078}
1079
1080fn blank_dropped_content(out: &mut [u8], scan: &[u8], start: usize, end: usize) {
1086 let mut i = start;
1087 while i < end {
1088 let mut open = i;
1089 while open < end && scan[open] != b'{' && scan[open] != b';' {
1090 open += 1;
1091 }
1092 if open >= end {
1093 blank_span(out, i, end);
1094 break;
1095 }
1096 if scan[open] == b';' {
1097 blank_span(out, i, open + 1);
1098 i = open + 1;
1099 continue;
1100 }
1101 let mut depth = 0usize;
1102 let mut close = end;
1103 let mut balanced = false;
1104 for (k, &byte) in scan.iter().enumerate().take(end).skip(open) {
1105 match byte {
1106 b'{' => depth += 1,
1107 b'}' => {
1108 depth -= 1;
1109 if depth == 0 {
1110 close = k;
1111 balanced = true;
1112 break;
1113 }
1114 }
1115 _ => {}
1116 }
1117 }
1118 if !balanced {
1119 blank_span(out, i, end);
1120 break;
1121 }
1122 let is_at_rule = scan[i..open]
1123 .iter()
1124 .position(|b| !b.is_ascii_whitespace())
1125 .is_some_and(|k| scan[i + k] == b'@');
1126 if is_at_rule {
1127 blank_dropped_content(out, scan, open + 1, close);
1128 } else if !has_important(&scan[open + 1..close]) {
1129 blank_span(out, i, close + 1);
1130 }
1131 i = close + 1;
1132 }
1133}
1134
1135fn has_important(bytes: &[u8]) -> bool {
1138 let keyword = b"important";
1139 let mut i = 0;
1140 while i < bytes.len() {
1141 if bytes[i] == b'!' {
1142 let mut k = i + 1;
1143 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
1144 k += 1;
1145 }
1146 if k + keyword.len() <= bytes.len()
1147 && bytes[k..k + keyword.len()].eq_ignore_ascii_case(keyword)
1148 {
1149 return true;
1150 }
1151 }
1152 i += 1;
1153 }
1154 false
1155}
1156
1157fn layers_ordered_below_makeover(css: &str) -> Vec<String> {
1165 let scan = blank(css, Lang::Css);
1166 let mut below = Vec::new();
1167 for statement in scan.split(';') {
1168 let Some(at) = statement.rfind("@layer") else {
1169 continue;
1170 };
1171 let names: Vec<&str> = statement[at + "@layer".len()..]
1172 .split(',')
1173 .map(str::trim)
1174 .collect();
1175 if names.iter().any(|name| name.contains(['{', '}'])) {
1176 continue;
1177 }
1178 if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
1179 below.extend(names[..position].iter().map(|name| (*name).to_string()));
1180 }
1181 }
1182 below
1183}
1184
1185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1192pub enum Allow<'a> {
1193 Class {
1195 class: &'a str,
1197 property: &'a str,
1199 },
1200 Element {
1203 element: &'a str,
1205 class: &'a str,
1207 property: &'a str,
1209 },
1210}
1211
1212impl<'a> Allow<'a> {
1213 pub const fn class(class: &'a str, property: &'a str) -> Self {
1215 Self::Class { class, property }
1216 }
1217
1218 pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
1220 Self::Element {
1221 element,
1222 class,
1223 property,
1224 }
1225 }
1226}
1227
1228#[derive(Debug, Clone)]
1305#[must_use = "a check does nothing until it is run"]
1306pub struct Vocabulary<'a> {
1307 source: Source,
1308 generated: Vec<&'a str>,
1309 upstream: Vec<&'a str>,
1310 allowed: Vec<Allow<'a>>,
1311 beneath: Vec<&'a str>,
1312}
1313
1314impl<'a> Vocabulary<'a> {
1315 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
1317 Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
1318 }
1319
1320 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
1326 Self::from(Source::files(paths))
1327 }
1328
1329 fn from(source: Source) -> Self {
1330 Self {
1331 source,
1332 generated: Vec::new(),
1333 upstream: Vec::new(),
1334 allowed: Vec::new(),
1335 beneath: Vec::new(),
1336 }
1337 }
1338
1339 pub fn generated(mut self, names: &[&'a str]) -> Self {
1344 self.generated.extend_from_slice(names);
1345 self
1346 }
1347
1348 pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
1358 self.upstream.extend_from_slice(sheets);
1359 self
1360 }
1361
1362 pub fn beneath(mut self, layers: &[&'a str]) -> Self {
1372 self.beneath.extend_from_slice(layers);
1373 self
1374 }
1375
1376 pub fn allow(mut self, allow: Allow<'a>) -> Self {
1378 self.allowed.push(allow);
1379 self
1380 }
1381
1382 pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
1384 self.allowed.extend(allows);
1385 self
1386 }
1387
1388 pub fn run(&self, opts: &Emit) {
1399 let classes: Vec<(&str, &str)> = self
1400 .allowed
1401 .iter()
1402 .filter_map(|allow| match *allow {
1403 Allow::Class { class, property } => Some((class, property)),
1404 Allow::Element { .. } => None,
1405 })
1406 .collect();
1407 let elements: Vec<(&str, &str, &str)> = self
1408 .allowed
1409 .iter()
1410 .filter_map(|allow| match *allow {
1411 Allow::Element {
1412 element,
1413 class,
1414 property,
1415 } => Some((element, class, property)),
1416 Allow::Class { .. } => None,
1417 })
1418 .collect();
1419 match &self.source {
1420 Source::Frontend(frontend) => {
1421 let css = frontend.join("css");
1422 let files: Vec<PathBuf> = files_with_extension(&css, "css")
1423 .into_iter()
1424 .filter(|p| {
1425 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
1426 !self.generated.contains(&name.as_str())
1427 })
1428 .collect();
1429 check_vocabulary_paths(
1430 &files,
1431 opts,
1432 Some(frontend),
1433 &self.upstream,
1434 &self.beneath,
1435 &classes,
1436 &elements,
1437 );
1438 println!("cargo::rerun-if-changed={}", css.display());
1440 }
1441 Source::Files(paths) => {
1442 let files: Vec<PathBuf> = paths
1443 .iter()
1444 .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
1445 .cloned()
1446 .collect();
1447 check_vocabulary_paths(
1448 &files,
1449 opts,
1450 None,
1451 &self.upstream,
1452 &self.beneath,
1453 &classes,
1454 &elements,
1455 );
1456 }
1457 }
1458 }
1459}
1460
1461fn check_vocabulary_paths(
1463 paths: &[PathBuf],
1464 opts: &Emit,
1465 root: Option<&Path>,
1466 upstream: &[&str],
1467 beneath: &[&str],
1468 allowed: &[(&str, &str)],
1469 allowed_elements: &[(&str, &str, &str)],
1470) {
1471 let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
1472 let mut owned = makeover.clone();
1475 for sheet in upstream {
1476 for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
1477 owned.entry(class).or_default().extend(properties);
1478 }
1479 }
1480 let makeover_variants = crate::vocabulary::variants_by_class(&crate::stylesheet(opts));
1483 let mut owned_variants = makeover_variants.clone();
1484 for sheet in upstream {
1485 for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
1486 let entry = owned_variants.entry(class).or_default();
1487 for (arm, properties) in arms {
1488 entry.entry(arm).or_default().extend(properties);
1489 }
1490 }
1491 }
1492 let mut cancels: Vec<String> = Vec::new();
1493 let mut clashes: Vec<String> = Vec::new();
1494 let mut seen: Vec<(String, String)> = Vec::new();
1495 let mut element_clashes: Vec<String> = Vec::new();
1496 let mut element_seen: Vec<(String, String, String)> = Vec::new();
1497
1498 for path in paths {
1499 println!("cargo::rerun-if-changed={}", path.display());
1500 let raw = std::fs::read_to_string(path)
1501 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1502 let name = match root {
1503 Some(root) => display_name(root, path),
1504 None => path.display().to_string(),
1505 };
1506 let (generated, variants) = if upstream.contains(&raw.as_str()) {
1509 (&makeover, &makeover_variants)
1510 } else {
1511 (&owned, &owned_variants)
1512 };
1513 let raw = drop_layers(&raw, beneath);
1516 let local = crate::vocabulary::declarations_by_class(&raw);
1519 for (class, properties) in &local {
1520 let Some(theirs) = generated.get(class) else {
1521 continue;
1522 };
1523 for property in properties.intersection(theirs) {
1524 seen.push((class.clone(), property.clone()));
1525 if allowed.contains(&(class.as_str(), property.as_str())) {
1526 continue;
1527 }
1528 clashes.push(format!(" {name} .{class} {{ {property} }}"));
1529 }
1530 }
1531
1532 let own_below = layers_ordered_below_makeover(&raw);
1543 let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
1544 let above = drop_layers(&raw, &own_below);
1545 let bare = crate::vocabulary::bare_declarations(&above);
1546 let restated = crate::vocabulary::mentions_by_arm(&raw);
1547 for (class, properties) in &bare {
1548 let Some(arms) = variants.get(class) else {
1549 continue;
1550 };
1551 for property in properties {
1552 let lost: Vec<&str> = arms
1553 .iter()
1554 .filter(|(arm, theirs)| {
1555 theirs.contains(property)
1556 && !restated
1557 .get(arm.as_str())
1558 .is_some_and(|mentioned| mentioned.contains(property))
1559 })
1560 .map(|(arm, _)| arm.as_str())
1561 .collect();
1562 if lost.is_empty() {
1563 continue;
1564 }
1565 cancels.push(format!(
1566 " {name} .{class} {{ {property} }} cancels {}",
1567 lost.iter()
1568 .map(|arm| format!("{arm} {{ {property} }}"))
1569 .collect::<Vec<_>>()
1570 .join(", ")
1571 ));
1572 }
1573 }
1574
1575 let mentioned = crate::vocabulary::mentions_by_class(&raw);
1581 let by_element = crate::vocabulary::declarations_by_element(&raw);
1582 for (element, properties) in &by_element {
1583 for class in crate::vocabulary::classes_for_element(element, opts) {
1584 let Some(theirs) = generated.get(&class) else {
1585 continue;
1586 };
1587 for (property, rank) in properties {
1588 if !theirs.contains(property) {
1589 continue;
1590 }
1591 let spoken_for = mentioned
1595 .get(&class)
1596 .and_then(|properties| properties.get(property))
1597 .is_some_and(|theirs| theirs >= rank);
1598 if spoken_for {
1599 continue;
1600 }
1601 element_seen.push((element.clone(), class.clone(), property.clone()));
1602 if allowed_elements.contains(&(
1603 element.as_str(),
1604 class.as_str(),
1605 property.as_str(),
1606 )) {
1607 continue;
1608 }
1609 element_clashes.push(format!(
1610 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
1611 ));
1612 }
1613 }
1614 }
1615 }
1616
1617 assert!(
1618 cancels.is_empty(),
1619 "{} hand-written rule(s) on a bare generated class set a property the \
1620 design system varies on that class by state, tone or context. The app \
1621 sheet sits in a later cascade layer than @layer makeover, and layers \
1622 resolve before specificity, so each of these beats every variant named \
1623 beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
1624 cancelled variant in the app's sheet on its own arm, after the bare rule \
1625 (`color: revert-layer` hands the design system's value back). That is \
1626 the remedy the cascade allows; an allowed-list entry does not cover \
1627 this, because the reviewed pair is the class and the variants are \
1628 what it silently takes.",
1629 cancels.len(),
1630 cancels.join("\n")
1631 );
1632
1633 assert!(
1634 clashes.is_empty(),
1635 "{} hand-written declaration(s) take a property the generated stylesheet, \
1636 or an upstream sheet named to this check, already sets on the same \
1637 class. App CSS wins over @layer makeover, whether by a later layer or \
1638 by being unlayered, so each of these wins over the design system \
1639 silently:\n{}\n\nDelete the declaration, or, if \
1640 it is a deliberate pairing on a different selector arm, add \
1641 (class, property) to this check's allowed list and say why beside it. \
1642 Count the consumers before deciding a divergence is worth keeping.",
1643 clashes.len(),
1644 clashes.join("\n")
1645 );
1646
1647 assert!(
1648 element_clashes.is_empty(),
1649 "{} hand-written element rule(s) take a property the generated \
1650 stylesheet sets on a class that element carries. App CSS wins over \
1651 @layer makeover, whether by a later layer or by being unlayered, so a \
1652 described component rendered on one of these elements loses the \
1653 design system's version of that property silently, which can draw a \
1654 destructive act like an ordinary one:\n{}\n\nHand the \
1655 property back on the arms makeover paints \
1656 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1657 rule so it stops reaching described markup, or add \
1658 (element, class, property) to this check's allowed-elements list and \
1659 say why beside it.",
1660 element_clashes.len(),
1661 element_clashes.join("\n")
1662 );
1663
1664 let stale: Vec<&(&str, &str)> = allowed
1665 .iter()
1666 .filter(|(class, property)| {
1667 !seen.contains(&((*class).to_string(), (*property).to_string()))
1668 })
1669 .collect();
1670 assert!(
1671 stale.is_empty(),
1672 "the allowed list declares {stale:?}, which no longer collides with \
1673 anything. Delete the entries: an exception nobody is using is where the \
1674 next real collision lands and reads as company."
1675 );
1676
1677 let stale: Vec<&(&str, &str, &str)> = allowed_elements
1678 .iter()
1679 .filter(|(element, class, property)| {
1680 !element_seen.contains(&(
1681 (*element).to_string(),
1682 (*class).to_string(),
1683 (*property).to_string(),
1684 ))
1685 })
1686 .collect();
1687 assert!(
1688 stale.is_empty(),
1689 "the allowed-elements list declares {stale:?}, which no longer collides \
1690 with anything. Delete the entries: an exception nobody is using is \
1691 where the next real collision lands and reads as company."
1692 );
1693}
1694
1695fn haystack_names(haystack: &str, class: &str) -> bool {
1699 let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
1700 haystack.match_indices(class).any(|(at, found)| {
1701 haystack[..at].chars().next_back().is_none_or(|c| !part(c))
1702 && haystack[at + found.len()..]
1703 .chars()
1704 .next()
1705 .is_none_or(|c| !part(c))
1706 })
1707}
1708
1709pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1733 let generated = crate::vocabulary::names(opts);
1734 let mut haystack = String::new();
1735 for path in markup {
1736 let path = path.as_ref();
1737 println!("cargo::rerun-if-changed={}", path.display());
1738 haystack.push_str(
1739 &std::fs::read_to_string(path)
1740 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
1741 );
1742 haystack.push('\n');
1743 }
1744
1745 const LADDERS: &[&str] = &[
1751 "min-",
1752 "short-",
1753 "stacks-",
1754 "drop-optional-",
1755 "drop-secondary-",
1756 ];
1757 let rung_of = |class: &str| -> Option<&'static str> {
1758 let name = class.strip_prefix(opts.class_prefix)?;
1759 LADDERS.iter().copied().find(|ladder| {
1760 name.strip_prefix(ladder)
1761 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1762 })
1763 };
1764 let mut unused: Vec<String> = generated
1765 .iter()
1766 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1767 .cloned()
1768 .collect();
1769 for ladder in LADDERS {
1770 let rungs: Vec<&String> = generated
1771 .iter()
1772 .filter(|class| rung_of(class) == Some(ladder))
1773 .collect();
1774 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1775 unused.push(format!("{}{ladder}N", opts.class_prefix));
1776 }
1777 }
1778
1779 assert!(
1780 unused.len() <= high_water,
1781 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1782 The vocabulary grew or the markup stopped using it:\n{}",
1783 unused.len(),
1784 generated.len(),
1785 high_water,
1786 unused
1787 .iter()
1788 .map(|c| format!(" .{c}"))
1789 .collect::<Vec<_>>()
1790 .join("\n")
1791 );
1792
1793 if unused.len() < high_water {
1794 println!(
1795 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1796 lower the seal so it cannot grow back",
1797 unused.len(),
1798 high_water
1799 );
1800 }
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use super::*;
1806 use std::fmt::Write as _;
1807
1808 fn scratch(name: &str) -> PathBuf {
1809 let dir =
1810 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1811 let _ = std::fs::remove_dir_all(&dir);
1812 std::fs::create_dir_all(&dir).expect("create scratch");
1813 dir
1814 }
1815
1816 fn write(dir: &Path, name: &str, src: &str) {
1817 if let Some(parent) = dir.join(name).parent() {
1818 std::fs::create_dir_all(parent).unwrap();
1819 }
1820 std::fs::write(dir.join(name), src).unwrap();
1821 }
1822
1823 fn declaring() -> String {
1824 format!(
1825 "const {CONST_NAME} = '{}';\n",
1826 Density::Touch.media_condition()
1827 )
1828 }
1829
1830 #[test]
1831 fn the_crates_own_string_passes() {
1832 let dir = scratch("ok");
1833 write(&dir, "touch.js", &declaring());
1834 check_touch_density(&dir);
1835 }
1836
1837 #[test]
1838 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1839 fn a_drifted_literal_fails() {
1840 let dir = scratch("drift");
1841 write(&dir, "touch.js", &declaring());
1842 write(
1843 &dir,
1844 "haptics.js",
1845 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1846 );
1847 check_touch_density(&dir);
1848 }
1849
1850 #[test]
1851 #[should_panic(expected = "device sniff")]
1852 fn the_sniff_cannot_come_back() {
1853 let dir = scratch("sniff");
1854 write(&dir, "touch.js", &declaring());
1855 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1856 check_touch_density(&dir);
1857 }
1858
1859 #[test]
1860 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1861 fn a_frontend_that_states_nothing_fails() {
1862 let dir = scratch("empty");
1863 write(&dir, "app.js", "export const x = 1;\n");
1864 check_touch_density(&dir);
1865 }
1866
1867 #[test]
1868 fn a_use_site_is_not_a_declaration() {
1869 let src =
1873 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1874 assert!(touch_density_literals(&src).is_empty());
1875 }
1876
1877 #[test]
1878 fn whitespace_and_backtick_literals_read() {
1879 let want = Density::Touch.media_condition();
1880 for src in [
1881 format!("const {CONST_NAME}='{want}';\n"),
1882 format!("const {CONST_NAME} = `{want}`;\n"),
1883 format!("const {CONST_NAME} =\n '{want}';\n"),
1884 ] {
1885 let found = touch_density_literals(&src);
1886 assert_eq!(found.len(), 1, "src: {src:?}");
1887 assert_eq!(found[0].1, want, "src: {src:?}");
1888 }
1889 }
1890
1891 #[test]
1892 fn a_longer_identifier_is_not_the_const() {
1893 let want = Density::Touch.media_condition();
1895 for src in [
1896 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1897 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1898 ] {
1899 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1900 }
1901 }
1902
1903 #[test]
1904 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1905 fn a_commented_out_declaration_is_not_a_declaration() {
1906 let dir = scratch("commented-decl");
1909 write(
1910 &dir,
1911 "touch.js",
1912 &format!("// {}\n", declaring().trim_end()),
1913 );
1914 check_touch_density(&dir);
1915 }
1916
1917 #[test]
1918 fn a_sniff_named_in_a_comment_is_not_a_use() {
1919 let dir = scratch("sniff-comment");
1921 write(&dir, "touch.js", &declaring());
1922 write(
1923 &dir,
1924 "note.js",
1925 "// never read ontouchstart or maxTouchPoints here\n",
1926 );
1927 check_touch_density(&dir);
1928 }
1929
1930 #[test]
1931 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1932 fn a_wrong_literal_without_spaces_is_caught() {
1933 let dir = scratch("loose-drift");
1935 write(&dir, "touch.js", &declaring());
1936 write(
1937 &dir,
1938 "haptics.js",
1939 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1940 );
1941 check_touch_density(&dir);
1942 }
1943
1944 #[test]
1945 fn nested_files_are_read() {
1946 let dir = scratch("nested");
1949 write(&dir, "touch.js", &declaring());
1950 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1951 let files = js_files(&dir);
1952 assert_eq!(files.len(), 2);
1953 }
1954
1955 #[test]
1956 fn a_non_js_file_is_ignored() {
1957 let dir = scratch("nonjs");
1958 write(&dir, "touch.js", &declaring());
1959 write(&dir, "styles.css", "body { }\n");
1960 assert_eq!(js_files(&dir).len(), 1);
1961 }
1962
1963 fn frontend(name: &str) -> PathBuf {
1964 let dir = scratch(name);
1965 std::fs::create_dir_all(dir.join("css")).unwrap();
1966 std::fs::create_dir_all(dir.join("js")).unwrap();
1967 dir
1968 }
1969
1970 fn boundary() -> u16 {
1972 SizeClass::Medium.min_px()
1973 }
1974
1975 #[test]
1976 fn the_crates_own_boundaries_pass() {
1977 let dir = frontend("bp-ok");
1978 write(
1979 &dir,
1980 "css/styles.css",
1981 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1982 );
1983 Breakpoints::in_frontend(&dir).run();
1984 }
1985
1986 #[test]
1987 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1988 fn a_stale_css_width_fails() {
1989 let dir = frontend("bp-css");
1990 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1991 Breakpoints::in_frontend(&dir).run();
1992 }
1993
1994 #[test]
1995 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1996 fn a_stale_js_width_fails() {
1997 let dir = frontend("bp-js");
1998 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1999 Breakpoints::in_frontend(&dir).run();
2000 }
2001
2002 #[test]
2003 fn a_declared_tuning_width_passes() {
2004 let dir = frontend("bp-tuning");
2005 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
2006 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2007 }
2008
2009 #[test]
2010 fn a_container_query_on_a_shell_boundary_passes() {
2011 let dir = frontend("bp-container-ok");
2015 write(
2016 &dir,
2017 "css/styles.css",
2018 &format!("@container screen (width < {}px) {{ }}\n", boundary()),
2019 );
2020 Breakpoints::in_frontend(&dir).run();
2021 }
2022
2023 #[test]
2024 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
2025 fn a_stale_container_width_fails() {
2026 let dir = frontend("bp-container-stale");
2027 write(
2028 &dir,
2029 "css/styles.css",
2030 "@container screen (width < 768px) { }\n",
2031 );
2032 Breakpoints::in_frontend(&dir).run();
2033 }
2034
2035 #[test]
2036 fn a_container_inline_size_condition_is_not_a_width_boundary() {
2037 let dir = frontend("bp-container-inline");
2040 write(
2041 &dir,
2042 "css/styles.css",
2043 "@container tab-strip (inline-size < 36rem) { }\n",
2044 );
2045 Breakpoints::in_frontend(&dir).run();
2046 }
2047
2048 #[test]
2049 fn a_width_in_a_comment_is_prose() {
2050 let dir = frontend("bp-comment");
2053 write(
2054 &dir,
2055 "css/styles.css",
2056 "/* was @media (max-width: 768px) until the size classes landed */\n",
2057 );
2058 Breakpoints::in_frontend(&dir).run();
2059 }
2060
2061 #[test]
2062 fn an_unparenthesized_width_is_not_a_breakpoint() {
2063 let dir = frontend("bp-inline");
2067 write(
2068 &dir,
2069 "js/style.js",
2070 "el.style.cssText = 'max-width: 320px; display: block';\n",
2071 );
2072 Breakpoints::in_frontend(&dir).run();
2073 }
2074
2075 #[test]
2076 fn nested_css_is_read() {
2077 let dir = frontend("bp-nested");
2080 write(
2081 &dir,
2082 "css/screens/detail.css",
2083 "@media (max-width: 768px) { }\n",
2084 );
2085 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
2086 assert!(found.is_err(), "a nested stylesheet must be scanned");
2087 }
2088
2089 #[test]
2090 fn a_css_only_frontend_scans_without_a_js_directory() {
2091 let dir = scratch("bp-css-only");
2094 write(
2095 &dir,
2096 "css/styles.css",
2097 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
2098 );
2099 Breakpoints::in_frontend(&dir).run();
2100 }
2101
2102 #[test]
2103 fn a_named_list_is_checked() {
2104 let dir = frontend("bp-list");
2105 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
2106 let listed = dir.join("css/style.css");
2107 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
2108 let msg = err.downcast_ref::<String>().expect("String payload");
2109 assert!(msg.contains("style.css:1"), "got: {msg}");
2110 }
2111
2112 #[test]
2113 #[should_panic(expected = "read ")]
2114 fn a_listed_file_that_is_gone_fails() {
2115 let dir = frontend("bp-missing");
2118 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
2119 }
2120
2121 #[test]
2122 fn a_listed_js_file_is_parsed_as_script() {
2123 let dir = frontend("bp-list-js");
2126 write(
2127 &dir,
2128 "js/style.js",
2129 "el.style.cssText = 'max-width: 320px';\n",
2130 );
2131 Breakpoints::files(&[dir.join("js/style.js")]).run();
2132 }
2133
2134 #[test]
2135 fn the_error_names_the_file_and_line() {
2136 let dir = frontend("bp-message");
2137 write(
2138 &dir,
2139 "css/styles.css",
2140 "body { }\n@media (max-width: 768px) { }\n",
2141 );
2142 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
2143 let msg = err
2144 .downcast_ref::<String>()
2145 .expect("panic payload is a String");
2146 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
2147 }
2148
2149 #[test]
2150 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
2151 let upstream = ".tile-set { gap: 4px; }\n";
2154 let dir = scratch("vocab-upstream");
2155 write(&dir, "css/quasi.css", upstream);
2156 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
2157 let err = std::panic::catch_unwind(|| {
2158 Vocabulary::in_frontend(&dir)
2159 .upstream(&[upstream])
2160 .run(&Emit::default());
2161 })
2162 .unwrap_err();
2163 let msg = err
2164 .downcast_ref::<String>()
2165 .expect("panic payload is a String");
2166 assert!(
2167 msg.contains("css/styles.css .tile-set { gap }"),
2168 "got: {msg}"
2169 );
2170 assert!(
2171 !msg.contains("css/quasi.css"),
2172 "the served copy clashed with itself: {msg}"
2173 );
2174
2175 Vocabulary::in_frontend(&dir).run(&Emit::default());
2178 }
2179
2180 #[test]
2181 fn a_rule_restating_a_generated_class_fails_and_names_it() {
2182 let dir = scratch("vocab-clash");
2183 write(
2186 &dir,
2187 "css/styles.css",
2188 "body { color: red; }\n.card { box-shadow: none; }\n",
2189 );
2190 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2191 .unwrap_err();
2192 let msg = err
2193 .downcast_ref::<String>()
2194 .expect("panic payload is a String");
2195 assert!(msg.contains(".card"), "got: {msg}");
2196 assert!(msg.contains("box-shadow"), "got: {msg}");
2197 assert!(msg.contains("css/styles.css"), "got: {msg}");
2198 }
2199
2200 #[test]
2201 fn an_app_class_of_its_own_is_left_alone() {
2202 let dir = scratch("vocab-clean");
2203 write(
2204 &dir,
2205 "css/styles.css",
2206 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
2207 );
2208 Vocabulary::in_frontend(&dir).run(&Emit::default());
2209 }
2210
2211 #[test]
2212 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
2213 let dir = scratch("vocab-generated");
2214 let opts = Emit::default();
2215 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
2216 Vocabulary::in_frontend(&dir)
2219 .generated(&["layout.css"])
2220 .run(&opts);
2221 }
2222
2223 #[test]
2224 fn a_prefixed_app_is_checked_against_its_own_prefix() {
2225 let dir = scratch("vocab-prefix");
2226 let opts = Emit::new().class_prefix("mo-");
2227 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
2230 Vocabulary::in_frontend(&dir).run(&opts);
2231
2232 let dir = scratch("vocab-prefix-clash");
2233 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
2234 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
2235 }
2236
2237 #[test]
2238 fn a_class_shared_without_a_shared_property_is_left_alone() {
2239 let dir = scratch("vocab-additive");
2240 write(
2243 &dir,
2244 "css/styles.css",
2245 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
2246 );
2247 Vocabulary::in_frontend(&dir).run(&Emit::default());
2248 }
2249
2250 #[test]
2251 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
2252 let dir = scratch("vocab-allowed");
2253 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
2254 Vocabulary::in_frontend(&dir)
2255 .allow(Allow::class("badge", "padding"))
2256 .run(&Emit::default());
2257
2258 let dir = scratch("vocab-allowed-stale");
2261 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
2262 let err = std::panic::catch_unwind(|| {
2263 Vocabulary::in_frontend(&dir)
2264 .allow(Allow::class("badge", "padding"))
2265 .run(&Emit::default());
2266 })
2267 .unwrap_err();
2268 let msg = err
2269 .downcast_ref::<String>()
2270 .expect("panic payload is a String");
2271 assert!(msg.contains("no longer collides"), "got: {msg}");
2272 }
2273
2274 #[test]
2275 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2276 let dir = scratch("vocab-element");
2277 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2281 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2282 .unwrap_err();
2283 let msg = err
2284 .downcast_ref::<String>()
2285 .expect("panic payload is a String");
2286 assert!(msg.contains("select {"), "got: {msg}");
2287 assert!(msg.contains(".field"), "got: {msg}");
2288 assert!(msg.contains("box-shadow"), "got: {msg}");
2289 assert!(msg.contains("css/styles.css"), "got: {msg}");
2290 }
2291
2292 #[test]
2293 fn a_universal_reset_after_the_design_system_is_caught() {
2294 let dir = scratch("vocab-universal");
2298 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2299 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2300 .unwrap_err();
2301 let msg = err
2302 .downcast_ref::<String>()
2303 .expect("panic payload is a String");
2304 assert!(msg.contains("* {"), "got: {msg}");
2305 assert!(msg.contains("padding"), "got: {msg}");
2306 }
2307
2308 #[test]
2309 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2310 let dir = scratch("vocab-universal-beneath");
2311 write(
2312 &dir,
2313 "css/styles.css",
2314 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2315 );
2316 Vocabulary::in_frontend(&dir)
2317 .beneath(&["reset"])
2318 .run(&Emit::default());
2319 }
2320
2321 #[test]
2322 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2323 let dir = scratch("vocab-important-beneath");
2324 write(
2329 &dir,
2330 "css/styles.css",
2331 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2332 );
2333 let err = std::panic::catch_unwind(|| {
2334 Vocabulary::in_frontend(&dir)
2335 .beneath(&["reset"])
2336 .run(&Emit::default());
2337 })
2338 .unwrap_err();
2339 let msg = err.downcast_ref::<String>().expect("a String payload");
2340 assert!(msg.contains("box-shadow"), "got: {msg}");
2341 }
2342
2343 #[test]
2344 fn a_layer_not_named_beneath_is_still_read() {
2345 let dir = scratch("vocab-universal-other-layer");
2346 write(
2347 &dir,
2348 "css/styles.css",
2349 "@layer base {\n * { padding: 0; }\n}\n",
2350 );
2351 let err = std::panic::catch_unwind(|| {
2352 Vocabulary::in_frontend(&dir)
2353 .beneath(&["reset"])
2354 .run(&Emit::default());
2355 })
2356 .unwrap_err();
2357 let msg = err.downcast_ref::<String>().expect("a String payload");
2358 assert!(msg.contains("* {"), "got: {msg}");
2359 }
2360
2361 #[test]
2362 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2363 let dir = scratch("vocab-universal-box");
2364 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2365 Vocabulary::in_frontend(&dir).run(&Emit::default());
2366 }
2367
2368 #[test]
2369 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2370 let dir = scratch("vocab-element-handoff");
2371 write(
2376 &dir,
2377 "css/styles.css",
2378 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2379 );
2380 Vocabulary::in_frontend(&dir).run(&Emit::default());
2381 }
2382
2383 #[test]
2384 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2385 let dir = scratch("vocab-element-spoken-for");
2386 write(
2391 &dir,
2392 "css/styles.css",
2393 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2394 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2395 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2396 );
2397 Vocabulary::in_frontend(&dir)
2398 .allow(Allow::class("field", "box-shadow"))
2399 .run(&Emit::default());
2400 }
2401
2402 #[test]
2403 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2404 let dir = scratch("vocab-element-weak-handoff");
2405 write(
2409 &dir,
2410 "css/styles.css",
2411 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2412 );
2413 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2414 .unwrap_err();
2415 let msg = err
2416 .downcast_ref::<String>()
2417 .expect("panic payload is a String");
2418 assert!(msg.contains(".field"), "got: {msg}");
2419
2420 let dir = scratch("vocab-element-strong-handoff");
2422 write(
2423 &dir,
2424 "css/styles.css",
2425 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2426 );
2427 Vocabulary::in_frontend(&dir).run(&Emit::default());
2428 }
2429
2430 #[test]
2431 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2432 let dir = scratch("vocab-element-scoped");
2433 write(
2437 &dir,
2438 "css/styles.css",
2439 ".wizard select { box-shadow: none; }\n",
2440 );
2441 Vocabulary::in_frontend(&dir).run(&Emit::default());
2442 }
2443
2444 #[test]
2445 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2446 let dir = scratch("vocab-element-unpaired");
2447 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2450 Vocabulary::in_frontend(&dir).run(&Emit::default());
2451 }
2452
2453 #[test]
2454 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2455 let dir = scratch("vocab-cancel-tone");
2460 write(
2461 &dir,
2462 "css/styles.css",
2463 ".button { background: var(--surface-raised); }\n",
2464 );
2465 let err = std::panic::catch_unwind(|| {
2466 Vocabulary::in_frontend(&dir)
2467 .allow(Allow::class("button", "background"))
2468 .run(&Emit::default());
2469 })
2470 .unwrap_err();
2471 let msg = err
2472 .downcast_ref::<String>()
2473 .expect("panic payload is a String");
2474 assert!(
2475 msg.contains(".button { background } cancels"),
2476 "got: {msg}"
2477 );
2478 assert!(
2479 msg.contains(".button[data-tone=\"danger\"] { background }"),
2480 "the message names the variant it cancels: {msg}"
2481 );
2482 assert!(msg.contains("restate"), "and the remedy: {msg}");
2483 }
2484
2485 #[test]
2486 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2487 let opts = Emit::default();
2490 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2491 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2492 for (arm, properties) in &variants["button"] {
2493 if properties.contains("background") {
2494 let loose = arm.replace('"', "'").replace(',', " , ");
2495 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2496 }
2497 }
2498 let dir = scratch("vocab-cancel-restated");
2499 write(&dir, "css/styles.css", &css);
2500 Vocabulary::in_frontend(&dir)
2501 .allow(Allow::class("button", "background"))
2502 .run(&opts);
2503 }
2504
2505 #[test]
2506 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2507 let dir = scratch("vocab-cancel-scoped");
2510 write(
2511 &dir,
2512 "css/styles.css",
2513 ".toolbar .button { color: var(--content); }\n\
2514 .button.quiet { color: var(--content); }\n",
2515 );
2516 Vocabulary::in_frontend(&dir)
2519 .allow(Allow::class("button", "color"))
2520 .run(&Emit::default());
2521 }
2522
2523 #[test]
2524 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2525 let dir = scratch("vocab-cancel-own-order");
2529 write(
2530 &dir,
2531 "css/quasi.css",
2532 "@layer reset, quasi, makeover;\n\
2533 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2534 );
2535 Vocabulary::in_frontend(&dir)
2536 .allow(Allow::class("button", "cursor"))
2537 .run(&Emit::default());
2538
2539 let dir = scratch("vocab-cancel-own-order-above");
2541 write(
2542 &dir,
2543 "css/app.css",
2544 "@layer reset, makeover, app;\n\
2545 @layer app {\n .button { cursor: pointer; }\n}\n",
2546 );
2547 let err = std::panic::catch_unwind(|| {
2548 Vocabulary::in_frontend(&dir)
2549 .allow(Allow::class("button", "cursor"))
2550 .run(&Emit::default());
2551 })
2552 .unwrap_err();
2553 let msg = err
2554 .downcast_ref::<String>()
2555 .expect("panic payload is a String");
2556 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2557 }
2558
2559 #[test]
2560 fn a_handoff_on_the_bare_class_cancels_nothing() {
2561 let dir = scratch("vocab-cancel-handoff");
2562 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2563 Vocabulary::in_frontend(&dir).run(&Emit::default());
2564 }
2565
2566 #[test]
2567 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2568 let dir = scratch("vocab-element-allowed");
2569 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2570 Vocabulary::in_frontend(&dir)
2571 .allow(Allow::element("select", "field", "box-shadow"))
2572 .run(&Emit::default());
2573
2574 let dir = scratch("vocab-element-allowed-stale");
2577 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2578 let err = std::panic::catch_unwind(|| {
2579 Vocabulary::in_frontend(&dir)
2580 .allow(Allow::element("select", "field", "box-shadow"))
2581 .run(&Emit::default());
2582 })
2583 .unwrap_err();
2584 let msg = err
2585 .downcast_ref::<String>()
2586 .expect("panic payload is a String");
2587 assert!(msg.contains("no longer collides"), "got: {msg}");
2588 }
2589
2590 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2592 let mut rungs = 0;
2593 let mut ladders = 0;
2594 for ladder in [
2595 "min-",
2596 "short-",
2597 "stacks-",
2598 "drop-optional-",
2599 "drop-secondary-",
2600 ] {
2601 let here = names
2602 .iter()
2603 .filter(|c| {
2604 c.strip_prefix(ladder)
2605 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2606 })
2607 .count();
2608 rungs += here;
2609 ladders += usize::from(here > 0);
2610 }
2611 (rungs, ladders)
2612 }
2613
2614 #[test]
2615 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2616 let dir = scratch("vocab-seal");
2617 let opts = Emit::default();
2618 let names = crate::vocabulary::names(&opts);
2619 let (rungs, ladders) = ladders(&names);
2620 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2621 let dead = names.len() - rungs + ladders;
2624 write(&dir, "index.html", "<div></div>\n");
2625 let markup = [dir.join("index.html")];
2626
2627 check_vocabulary_use(&markup, &opts, dead);
2628 assert!(
2629 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2630 "a vocabulary deader than the seal has to fail"
2631 );
2632 }
2633
2634 #[test]
2635 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2636 let opts = Emit::default();
2639 let names = crate::vocabulary::names(&opts);
2640 let (rungs, ladders) = ladders(&names);
2641 let dead = names.len() - rungs + ladders;
2642
2643 let dir = scratch("vocab-ladder");
2644 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2645 let one_rung = [dir.join("index.html")];
2646 check_vocabulary_use(&one_rung, &opts, dead - 2);
2648
2649 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2651 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2652 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2653 }
2654
2655 #[test]
2656 fn a_class_is_named_whole_on_both_sides() {
2657 assert!(haystack_names("class=\"row\"", "row"));
2658 assert!(!haystack_names("class=\"row-flex\"", "row"));
2659 assert!(!haystack_names("class=\"flex-row\"", "row"));
2660 assert!(haystack_names("el.classList.add('row')", "row"));
2661 }
2662
2663 #[test]
2664 fn range_syntax_and_units_are_read() {
2665 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2666 assert_eq!(
2667 media_widths(" (600px <= width < 840px)"),
2668 [Width::Px(600), Width::Px(840)]
2669 );
2670 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2671 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2672 assert_eq!(
2673 media_widths(" (max-width: 599.98px)"),
2674 [Width::Other("599.98px".into())]
2675 );
2676 assert_eq!(
2677 media_widths(" (width < 50vw)"),
2678 [Width::Other("50vw".into())]
2679 );
2680 assert!(media_widths(" (min-height: 600px)").is_empty());
2681 assert!(media_widths(" (max-device-width: 600px)").is_empty());
2682 assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
2683 }
2684
2685 #[test]
2686 fn every_size_class_condition_passes_its_own_check() {
2687 let dir = frontend("bp-classes");
2688 let mut css = String::new();
2689 for class in SizeClass::all() {
2690 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2691 }
2692 write(&dir, "css/styles.css", &css);
2693 write(
2694 &dir,
2695 "js/shell.js",
2696 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2697 );
2698 Breakpoints::in_frontend(&dir).run();
2699 }
2700
2701 #[test]
2702 #[should_panic(expected = "48em")]
2703 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2704 let dir = frontend("bp-em");
2705 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2706 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2707 }
2708
2709 #[test]
2710 #[should_panic(expected = "(width < 768px)")]
2711 fn a_stale_range_width_fails() {
2712 let dir = frontend("bp-range");
2713 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2714 Breakpoints::in_frontend(&dir).run();
2715 }
2716
2717 #[test]
2718 #[should_panic(expected = "599px")]
2719 fn the_old_inclusive_compact_bound_is_stale() {
2720 let dir = frontend("bp-599");
2721 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2722 Breakpoints::in_frontend(&dir).run();
2723 }
2724
2725 #[test]
2726 #[should_panic(expected = "(max-width: 600px)")]
2727 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2728 let dir = frontend("bp-600-inclusive");
2732 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2733 Breakpoints::in_frontend(&dir).run();
2734 }
2735
2736 #[test]
2737 #[should_panic(expected = "(width <= 600px)")]
2738 fn an_inclusive_range_upper_bound_is_stale() {
2739 let dir = frontend("bp-le-600");
2740 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2741 Breakpoints::in_frontend(&dir).run();
2742 }
2743
2744 #[test]
2745 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2746 let dir = frontend("bp-edges-ok");
2747 write(
2748 &dir,
2749 "css/styles.css",
2750 "@media (min-width: 600px) { }\n\
2751 @media (600px <= width) { }\n\
2752 @media (width >= 840px) { }\n",
2753 );
2754 Breakpoints::in_frontend(&dir).run();
2755 }
2756
2757 #[test]
2758 fn a_tuning_max_width_is_left_alone() {
2759 let dir = frontend("bp-tuning-max");
2762 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2763 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2764 }
2765
2766 #[test]
2767 fn a_call_in_script_is_not_a_feature() {
2768 assert!(js_widths("const w = Math.max(width, 600);").is_empty());
2769 assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
2770 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2771 }
2772
2773 #[test]
2774 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2775 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2776 let stripped = blank(css, Lang::Css);
2777 assert!(!stripped.contains("768"), "{stripped:?}");
2778 assert!(stripped.contains("a { }"));
2779 assert_eq!(stripped.len(), css.len());
2780 }
2781
2782 #[test]
2783 fn a_comment_opener_inside_a_string_is_text() {
2784 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2786 let src = blank(css, Lang::Css);
2787 assert_eq!(media_conditions(&src).len(), 1);
2788 assert!(src.contains("768"));
2789
2790 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2791 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2792 }
2793
2794 #[test]
2795 fn a_regex_literal_does_not_open_a_comment() {
2796 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2799 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2800 }
2801
2802 #[test]
2803 fn a_division_is_not_a_regex_literal() {
2804 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2806 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2807 }
2808
2809 #[test]
2810 fn a_css_bad_string_ends_at_a_newline() {
2811 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2814 let src = blank(css, Lang::Css);
2815 assert_eq!(media_conditions(&src).len(), 1);
2816 assert!(src.contains("768"));
2817 }
2818
2819 #[test]
2820 fn an_escaped_quote_does_not_open_a_string() {
2821 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2822 let src = blank(css, Lang::Css);
2823 assert_eq!(media_conditions(&src).len(), 1);
2824 }
2825
2826 #[test]
2827 fn an_unquoted_url_does_not_open_a_comment() {
2828 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2829 let src = blank(css, Lang::Css);
2830 assert_eq!(media_conditions(&src).len(), 1);
2831 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2833 @media (max-width: 768px) { }\n";
2834 let src = blank(css, Lang::Css);
2835 assert!(!src.contains("gone"), "{src:?}");
2836 assert_eq!(media_conditions(&src).len(), 1);
2837 }
2838
2839 #[test]
2840 fn a_leading_dot_length_is_read() {
2841 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2843 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2844 }
2845
2846 #[test]
2847 fn an_at_media_keyword_reads_in_any_case() {
2848 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2849 assert_eq!(conditions.len(), 1);
2850 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2851 }
2852
2853 #[test]
2854 fn a_width_nested_in_a_function_is_read() {
2855 assert_eq!(
2856 media_widths("(max-width: calc(768px))"),
2857 vec![Width::Px(768)]
2858 );
2859 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2862 }
2863
2864 #[test]
2865 fn a_newline_does_not_reset_an_open_group() {
2866 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2867 }
2868
2869 #[test]
2870 fn custom_media_and_import_carry_media_queries() {
2871 let css = "@custom-media --narrow (max-width: 768px);\n\
2872 @import url(x.css) (max-width: 768px);\n";
2873 let conditions = media_conditions(css);
2874 assert_eq!(conditions.len(), 2);
2875 for (_, condition) in conditions {
2876 assert!(
2877 media_widths(condition).contains(&Width::Px(768)),
2878 "unread: {condition:?}"
2879 );
2880 }
2881 }
2882
2883 #[test]
2884 fn a_query_in_a_script_comment_is_prose() {
2885 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2886 assert!(js_widths(&blank(js, Lang::Js)).is_empty());
2887 }
2888
2889 #[test]
2890 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2891 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2892 let src = blank(css, Lang::Css);
2893 assert_eq!(media_conditions(&src).len(), 1);
2894 }
2895
2896 #[test]
2897 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2898 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2901 let src = blank(css, Lang::Css);
2902 assert_eq!(src.len(), css.len());
2903 let at = css.find("@layer").unwrap();
2904 assert_eq!(&src[at..at + 6], "@layer");
2905 let dropped = drop_layers(css, &["reset"]);
2906 assert!(!dropped.contains("padding"), "{dropped:?}");
2907 }
2908
2909 #[test]
2910 fn a_dropped_layer_keeps_its_important_rules() {
2911 let css =
2915 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2916 let dropped = drop_layers(css, &["reset"]);
2917 assert!(!dropped.contains("padding"), "{dropped:?}");
2918 assert!(dropped.contains(".card"), "{dropped:?}");
2919 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2920 }
2921
2922 #[test]
2923 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2924 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2925 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2926 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2927 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2928 }
2929
2930 #[test]
2931 fn blanking_keeps_lines_and_offsets() {
2932 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2933 let src = blank(css, Lang::Css);
2934 assert_eq!(src.len(), css.len());
2935 assert_eq!(src.lines().count(), css.lines().count());
2936 }
2937
2938 #[test]
2939 fn both_quote_styles_read() {
2940 let want = Density::Touch.media_condition();
2941 for q in ['\'', '"'] {
2942 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2943 let found = touch_density_literals(&src);
2944 assert_eq!(found.len(), 1);
2945 assert_eq!(found[0].1, want);
2946 }
2947 }
2948
2949 #[test]
2950 fn a_file_list_skips_the_sheets_named_generated() {
2951 let dir = frontend("vocab-files");
2952 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2953 write(&dir, "css/app.css", "body { color: red; }\n");
2954 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2955 Vocabulary::files(&files)
2956 .generated(&["layout.css"])
2957 .run(&Emit::default());
2958 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2959 .unwrap_err();
2960 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2961 assert!(msg.contains("box-shadow"), "got: {msg}");
2962 }
2963}