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] = &["min-", "short-"];
1751 let rung_of = |class: &str| -> Option<&'static str> {
1752 let name = class.strip_prefix(opts.class_prefix)?;
1753 LADDERS.iter().copied().find(|ladder| {
1754 name.strip_prefix(ladder)
1755 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1756 })
1757 };
1758 let mut unused: Vec<String> = generated
1759 .iter()
1760 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1761 .cloned()
1762 .collect();
1763 for ladder in LADDERS {
1764 let rungs: Vec<&String> = generated
1765 .iter()
1766 .filter(|class| rung_of(class) == Some(ladder))
1767 .collect();
1768 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1769 unused.push(format!("{}{ladder}N", opts.class_prefix));
1770 }
1771 }
1772
1773 assert!(
1774 unused.len() <= high_water,
1775 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1776 The vocabulary grew or the markup stopped using it:\n{}",
1777 unused.len(),
1778 generated.len(),
1779 high_water,
1780 unused
1781 .iter()
1782 .map(|c| format!(" .{c}"))
1783 .collect::<Vec<_>>()
1784 .join("\n")
1785 );
1786
1787 if unused.len() < high_water {
1788 println!(
1789 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1790 lower the seal so it cannot grow back",
1791 unused.len(),
1792 high_water
1793 );
1794 }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 use super::*;
1800 use std::fmt::Write as _;
1801
1802 fn scratch(name: &str) -> PathBuf {
1803 let dir =
1804 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1805 let _ = std::fs::remove_dir_all(&dir);
1806 std::fs::create_dir_all(&dir).expect("create scratch");
1807 dir
1808 }
1809
1810 fn write(dir: &Path, name: &str, src: &str) {
1811 if let Some(parent) = dir.join(name).parent() {
1812 std::fs::create_dir_all(parent).unwrap();
1813 }
1814 std::fs::write(dir.join(name), src).unwrap();
1815 }
1816
1817 fn declaring() -> String {
1818 format!(
1819 "const {CONST_NAME} = '{}';\n",
1820 Density::Touch.media_condition()
1821 )
1822 }
1823
1824 #[test]
1825 fn the_crates_own_string_passes() {
1826 let dir = scratch("ok");
1827 write(&dir, "touch.js", &declaring());
1828 check_touch_density(&dir);
1829 }
1830
1831 #[test]
1832 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1833 fn a_drifted_literal_fails() {
1834 let dir = scratch("drift");
1835 write(&dir, "touch.js", &declaring());
1836 write(
1837 &dir,
1838 "haptics.js",
1839 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1840 );
1841 check_touch_density(&dir);
1842 }
1843
1844 #[test]
1845 #[should_panic(expected = "device sniff")]
1846 fn the_sniff_cannot_come_back() {
1847 let dir = scratch("sniff");
1848 write(&dir, "touch.js", &declaring());
1849 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1850 check_touch_density(&dir);
1851 }
1852
1853 #[test]
1854 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1855 fn a_frontend_that_states_nothing_fails() {
1856 let dir = scratch("empty");
1857 write(&dir, "app.js", "export const x = 1;\n");
1858 check_touch_density(&dir);
1859 }
1860
1861 #[test]
1862 fn a_use_site_is_not_a_declaration() {
1863 let src =
1867 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1868 assert!(touch_density_literals(&src).is_empty());
1869 }
1870
1871 #[test]
1872 fn whitespace_and_backtick_literals_read() {
1873 let want = Density::Touch.media_condition();
1874 for src in [
1875 format!("const {CONST_NAME}='{want}';\n"),
1876 format!("const {CONST_NAME} = `{want}`;\n"),
1877 format!("const {CONST_NAME} =\n '{want}';\n"),
1878 ] {
1879 let found = touch_density_literals(&src);
1880 assert_eq!(found.len(), 1, "src: {src:?}");
1881 assert_eq!(found[0].1, want, "src: {src:?}");
1882 }
1883 }
1884
1885 #[test]
1886 fn a_longer_identifier_is_not_the_const() {
1887 let want = Density::Touch.media_condition();
1889 for src in [
1890 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1891 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1892 ] {
1893 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1894 }
1895 }
1896
1897 #[test]
1898 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1899 fn a_commented_out_declaration_is_not_a_declaration() {
1900 let dir = scratch("commented-decl");
1903 write(
1904 &dir,
1905 "touch.js",
1906 &format!("// {}\n", declaring().trim_end()),
1907 );
1908 check_touch_density(&dir);
1909 }
1910
1911 #[test]
1912 fn a_sniff_named_in_a_comment_is_not_a_use() {
1913 let dir = scratch("sniff-comment");
1915 write(&dir, "touch.js", &declaring());
1916 write(
1917 &dir,
1918 "note.js",
1919 "// never read ontouchstart or maxTouchPoints here\n",
1920 );
1921 check_touch_density(&dir);
1922 }
1923
1924 #[test]
1925 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1926 fn a_wrong_literal_without_spaces_is_caught() {
1927 let dir = scratch("loose-drift");
1929 write(&dir, "touch.js", &declaring());
1930 write(
1931 &dir,
1932 "haptics.js",
1933 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1934 );
1935 check_touch_density(&dir);
1936 }
1937
1938 #[test]
1939 fn nested_files_are_read() {
1940 let dir = scratch("nested");
1943 write(&dir, "touch.js", &declaring());
1944 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1945 let files = js_files(&dir);
1946 assert_eq!(files.len(), 2);
1947 }
1948
1949 #[test]
1950 fn a_non_js_file_is_ignored() {
1951 let dir = scratch("nonjs");
1952 write(&dir, "touch.js", &declaring());
1953 write(&dir, "styles.css", "body { }\n");
1954 assert_eq!(js_files(&dir).len(), 1);
1955 }
1956
1957 fn frontend(name: &str) -> PathBuf {
1958 let dir = scratch(name);
1959 std::fs::create_dir_all(dir.join("css")).unwrap();
1960 std::fs::create_dir_all(dir.join("js")).unwrap();
1961 dir
1962 }
1963
1964 fn boundary() -> u16 {
1966 SizeClass::Medium.min_px()
1967 }
1968
1969 #[test]
1970 fn the_crates_own_boundaries_pass() {
1971 let dir = frontend("bp-ok");
1972 write(
1973 &dir,
1974 "css/styles.css",
1975 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1976 );
1977 Breakpoints::in_frontend(&dir).run();
1978 }
1979
1980 #[test]
1981 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1982 fn a_stale_css_width_fails() {
1983 let dir = frontend("bp-css");
1984 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1985 Breakpoints::in_frontend(&dir).run();
1986 }
1987
1988 #[test]
1989 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1990 fn a_stale_js_width_fails() {
1991 let dir = frontend("bp-js");
1992 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1993 Breakpoints::in_frontend(&dir).run();
1994 }
1995
1996 #[test]
1997 fn a_declared_tuning_width_passes() {
1998 let dir = frontend("bp-tuning");
1999 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
2000 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2001 }
2002
2003 #[test]
2004 fn a_container_query_on_a_shell_boundary_passes() {
2005 let dir = frontend("bp-container-ok");
2009 write(
2010 &dir,
2011 "css/styles.css",
2012 &format!("@container screen (width < {}px) {{ }}\n", boundary()),
2013 );
2014 Breakpoints::in_frontend(&dir).run();
2015 }
2016
2017 #[test]
2018 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
2019 fn a_stale_container_width_fails() {
2020 let dir = frontend("bp-container-stale");
2021 write(
2022 &dir,
2023 "css/styles.css",
2024 "@container screen (width < 768px) { }\n",
2025 );
2026 Breakpoints::in_frontend(&dir).run();
2027 }
2028
2029 #[test]
2030 fn a_container_inline_size_condition_is_not_a_width_boundary() {
2031 let dir = frontend("bp-container-inline");
2034 write(
2035 &dir,
2036 "css/styles.css",
2037 "@container tab-strip (inline-size < 36rem) { }\n",
2038 );
2039 Breakpoints::in_frontend(&dir).run();
2040 }
2041
2042 #[test]
2043 fn a_width_in_a_comment_is_prose() {
2044 let dir = frontend("bp-comment");
2047 write(
2048 &dir,
2049 "css/styles.css",
2050 "/* was @media (max-width: 768px) until the size classes landed */\n",
2051 );
2052 Breakpoints::in_frontend(&dir).run();
2053 }
2054
2055 #[test]
2056 fn an_unparenthesized_width_is_not_a_breakpoint() {
2057 let dir = frontend("bp-inline");
2061 write(
2062 &dir,
2063 "js/style.js",
2064 "el.style.cssText = 'max-width: 320px; display: block';\n",
2065 );
2066 Breakpoints::in_frontend(&dir).run();
2067 }
2068
2069 #[test]
2070 fn nested_css_is_read() {
2071 let dir = frontend("bp-nested");
2074 write(
2075 &dir,
2076 "css/screens/detail.css",
2077 "@media (max-width: 768px) { }\n",
2078 );
2079 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
2080 assert!(found.is_err(), "a nested stylesheet must be scanned");
2081 }
2082
2083 #[test]
2084 fn a_css_only_frontend_scans_without_a_js_directory() {
2085 let dir = scratch("bp-css-only");
2088 write(
2089 &dir,
2090 "css/styles.css",
2091 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
2092 );
2093 Breakpoints::in_frontend(&dir).run();
2094 }
2095
2096 #[test]
2097 fn a_named_list_is_checked() {
2098 let dir = frontend("bp-list");
2099 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
2100 let listed = dir.join("css/style.css");
2101 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
2102 let msg = err.downcast_ref::<String>().expect("String payload");
2103 assert!(msg.contains("style.css:1"), "got: {msg}");
2104 }
2105
2106 #[test]
2107 #[should_panic(expected = "read ")]
2108 fn a_listed_file_that_is_gone_fails() {
2109 let dir = frontend("bp-missing");
2112 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
2113 }
2114
2115 #[test]
2116 fn a_listed_js_file_is_parsed_as_script() {
2117 let dir = frontend("bp-list-js");
2120 write(
2121 &dir,
2122 "js/style.js",
2123 "el.style.cssText = 'max-width: 320px';\n",
2124 );
2125 Breakpoints::files(&[dir.join("js/style.js")]).run();
2126 }
2127
2128 #[test]
2129 fn the_error_names_the_file_and_line() {
2130 let dir = frontend("bp-message");
2131 write(
2132 &dir,
2133 "css/styles.css",
2134 "body { }\n@media (max-width: 768px) { }\n",
2135 );
2136 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
2137 let msg = err
2138 .downcast_ref::<String>()
2139 .expect("panic payload is a String");
2140 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
2141 }
2142
2143 #[test]
2144 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
2145 let upstream = ".tile-set { gap: 4px; }\n";
2148 let dir = scratch("vocab-upstream");
2149 write(&dir, "css/quasi.css", upstream);
2150 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
2151 let err = std::panic::catch_unwind(|| {
2152 Vocabulary::in_frontend(&dir)
2153 .upstream(&[upstream])
2154 .run(&Emit::default());
2155 })
2156 .unwrap_err();
2157 let msg = err
2158 .downcast_ref::<String>()
2159 .expect("panic payload is a String");
2160 assert!(
2161 msg.contains("css/styles.css .tile-set { gap }"),
2162 "got: {msg}"
2163 );
2164 assert!(
2165 !msg.contains("css/quasi.css"),
2166 "the served copy clashed with itself: {msg}"
2167 );
2168
2169 Vocabulary::in_frontend(&dir).run(&Emit::default());
2172 }
2173
2174 #[test]
2175 fn a_rule_restating_a_generated_class_fails_and_names_it() {
2176 let dir = scratch("vocab-clash");
2177 write(
2180 &dir,
2181 "css/styles.css",
2182 "body { color: red; }\n.card { box-shadow: none; }\n",
2183 );
2184 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2185 .unwrap_err();
2186 let msg = err
2187 .downcast_ref::<String>()
2188 .expect("panic payload is a String");
2189 assert!(msg.contains(".card"), "got: {msg}");
2190 assert!(msg.contains("box-shadow"), "got: {msg}");
2191 assert!(msg.contains("css/styles.css"), "got: {msg}");
2192 }
2193
2194 #[test]
2195 fn an_app_class_of_its_own_is_left_alone() {
2196 let dir = scratch("vocab-clean");
2197 write(
2198 &dir,
2199 "css/styles.css",
2200 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
2201 );
2202 Vocabulary::in_frontend(&dir).run(&Emit::default());
2203 }
2204
2205 #[test]
2206 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
2207 let dir = scratch("vocab-generated");
2208 let opts = Emit::default();
2209 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
2210 Vocabulary::in_frontend(&dir)
2213 .generated(&["layout.css"])
2214 .run(&opts);
2215 }
2216
2217 #[test]
2218 fn a_prefixed_app_is_checked_against_its_own_prefix() {
2219 let dir = scratch("vocab-prefix");
2220 let opts = Emit::new().class_prefix("mo-");
2221 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
2224 Vocabulary::in_frontend(&dir).run(&opts);
2225
2226 let dir = scratch("vocab-prefix-clash");
2227 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
2228 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
2229 }
2230
2231 #[test]
2232 fn a_class_shared_without_a_shared_property_is_left_alone() {
2233 let dir = scratch("vocab-additive");
2234 write(
2237 &dir,
2238 "css/styles.css",
2239 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
2240 );
2241 Vocabulary::in_frontend(&dir).run(&Emit::default());
2242 }
2243
2244 #[test]
2245 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
2246 let dir = scratch("vocab-allowed");
2247 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
2248 Vocabulary::in_frontend(&dir)
2249 .allow(Allow::class("badge", "padding"))
2250 .run(&Emit::default());
2251
2252 let dir = scratch("vocab-allowed-stale");
2255 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
2256 let err = std::panic::catch_unwind(|| {
2257 Vocabulary::in_frontend(&dir)
2258 .allow(Allow::class("badge", "padding"))
2259 .run(&Emit::default());
2260 })
2261 .unwrap_err();
2262 let msg = err
2263 .downcast_ref::<String>()
2264 .expect("panic payload is a String");
2265 assert!(msg.contains("no longer collides"), "got: {msg}");
2266 }
2267
2268 #[test]
2269 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2270 let dir = scratch("vocab-element");
2271 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2275 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2276 .unwrap_err();
2277 let msg = err
2278 .downcast_ref::<String>()
2279 .expect("panic payload is a String");
2280 assert!(msg.contains("select {"), "got: {msg}");
2281 assert!(msg.contains(".field"), "got: {msg}");
2282 assert!(msg.contains("box-shadow"), "got: {msg}");
2283 assert!(msg.contains("css/styles.css"), "got: {msg}");
2284 }
2285
2286 #[test]
2287 fn a_universal_reset_after_the_design_system_is_caught() {
2288 let dir = scratch("vocab-universal");
2292 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2293 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2294 .unwrap_err();
2295 let msg = err
2296 .downcast_ref::<String>()
2297 .expect("panic payload is a String");
2298 assert!(msg.contains("* {"), "got: {msg}");
2299 assert!(msg.contains("padding"), "got: {msg}");
2300 }
2301
2302 #[test]
2303 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2304 let dir = scratch("vocab-universal-beneath");
2305 write(
2306 &dir,
2307 "css/styles.css",
2308 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2309 );
2310 Vocabulary::in_frontend(&dir)
2311 .beneath(&["reset"])
2312 .run(&Emit::default());
2313 }
2314
2315 #[test]
2316 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2317 let dir = scratch("vocab-important-beneath");
2318 write(
2323 &dir,
2324 "css/styles.css",
2325 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2326 );
2327 let err = std::panic::catch_unwind(|| {
2328 Vocabulary::in_frontend(&dir)
2329 .beneath(&["reset"])
2330 .run(&Emit::default());
2331 })
2332 .unwrap_err();
2333 let msg = err.downcast_ref::<String>().expect("a String payload");
2334 assert!(msg.contains("box-shadow"), "got: {msg}");
2335 }
2336
2337 #[test]
2338 fn a_layer_not_named_beneath_is_still_read() {
2339 let dir = scratch("vocab-universal-other-layer");
2340 write(
2341 &dir,
2342 "css/styles.css",
2343 "@layer base {\n * { padding: 0; }\n}\n",
2344 );
2345 let err = std::panic::catch_unwind(|| {
2346 Vocabulary::in_frontend(&dir)
2347 .beneath(&["reset"])
2348 .run(&Emit::default());
2349 })
2350 .unwrap_err();
2351 let msg = err.downcast_ref::<String>().expect("a String payload");
2352 assert!(msg.contains("* {"), "got: {msg}");
2353 }
2354
2355 #[test]
2356 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2357 let dir = scratch("vocab-universal-box");
2358 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2359 Vocabulary::in_frontend(&dir).run(&Emit::default());
2360 }
2361
2362 #[test]
2363 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2364 let dir = scratch("vocab-element-handoff");
2365 write(
2370 &dir,
2371 "css/styles.css",
2372 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2373 );
2374 Vocabulary::in_frontend(&dir).run(&Emit::default());
2375 }
2376
2377 #[test]
2378 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2379 let dir = scratch("vocab-element-spoken-for");
2380 write(
2385 &dir,
2386 "css/styles.css",
2387 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2388 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2389 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2390 );
2391 Vocabulary::in_frontend(&dir)
2392 .allow(Allow::class("field", "box-shadow"))
2393 .run(&Emit::default());
2394 }
2395
2396 #[test]
2397 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2398 let dir = scratch("vocab-element-weak-handoff");
2399 write(
2403 &dir,
2404 "css/styles.css",
2405 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2406 );
2407 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2408 .unwrap_err();
2409 let msg = err
2410 .downcast_ref::<String>()
2411 .expect("panic payload is a String");
2412 assert!(msg.contains(".field"), "got: {msg}");
2413
2414 let dir = scratch("vocab-element-strong-handoff");
2416 write(
2417 &dir,
2418 "css/styles.css",
2419 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2420 );
2421 Vocabulary::in_frontend(&dir).run(&Emit::default());
2422 }
2423
2424 #[test]
2425 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2426 let dir = scratch("vocab-element-scoped");
2427 write(
2431 &dir,
2432 "css/styles.css",
2433 ".wizard select { box-shadow: none; }\n",
2434 );
2435 Vocabulary::in_frontend(&dir).run(&Emit::default());
2436 }
2437
2438 #[test]
2439 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2440 let dir = scratch("vocab-element-unpaired");
2441 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2444 Vocabulary::in_frontend(&dir).run(&Emit::default());
2445 }
2446
2447 #[test]
2448 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2449 let dir = scratch("vocab-cancel-tone");
2454 write(
2455 &dir,
2456 "css/styles.css",
2457 ".button { background: var(--surface-raised); }\n",
2458 );
2459 let err = std::panic::catch_unwind(|| {
2460 Vocabulary::in_frontend(&dir)
2461 .allow(Allow::class("button", "background"))
2462 .run(&Emit::default());
2463 })
2464 .unwrap_err();
2465 let msg = err
2466 .downcast_ref::<String>()
2467 .expect("panic payload is a String");
2468 assert!(
2469 msg.contains(".button { background } cancels"),
2470 "got: {msg}"
2471 );
2472 assert!(
2473 msg.contains(".button[data-tone=\"danger\"] { background }"),
2474 "the message names the variant it cancels: {msg}"
2475 );
2476 assert!(msg.contains("restate"), "and the remedy: {msg}");
2477 }
2478
2479 #[test]
2480 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2481 let opts = Emit::default();
2484 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2485 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2486 for (arm, properties) in &variants["button"] {
2487 if properties.contains("background") {
2488 let loose = arm.replace('"', "'").replace(',', " , ");
2489 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2490 }
2491 }
2492 let dir = scratch("vocab-cancel-restated");
2493 write(&dir, "css/styles.css", &css);
2494 Vocabulary::in_frontend(&dir)
2495 .allow(Allow::class("button", "background"))
2496 .run(&opts);
2497 }
2498
2499 #[test]
2500 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2501 let dir = scratch("vocab-cancel-scoped");
2504 write(
2505 &dir,
2506 "css/styles.css",
2507 ".toolbar .button { color: var(--content); }\n\
2508 .button.quiet { color: var(--content); }\n",
2509 );
2510 Vocabulary::in_frontend(&dir)
2513 .allow(Allow::class("button", "color"))
2514 .run(&Emit::default());
2515 }
2516
2517 #[test]
2518 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2519 let dir = scratch("vocab-cancel-own-order");
2523 write(
2524 &dir,
2525 "css/quasi.css",
2526 "@layer reset, quasi, makeover;\n\
2527 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2528 );
2529 Vocabulary::in_frontend(&dir)
2530 .allow(Allow::class("button", "cursor"))
2531 .run(&Emit::default());
2532
2533 let dir = scratch("vocab-cancel-own-order-above");
2535 write(
2536 &dir,
2537 "css/app.css",
2538 "@layer reset, makeover, app;\n\
2539 @layer app {\n .button { cursor: pointer; }\n}\n",
2540 );
2541 let err = std::panic::catch_unwind(|| {
2542 Vocabulary::in_frontend(&dir)
2543 .allow(Allow::class("button", "cursor"))
2544 .run(&Emit::default());
2545 })
2546 .unwrap_err();
2547 let msg = err
2548 .downcast_ref::<String>()
2549 .expect("panic payload is a String");
2550 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2551 }
2552
2553 #[test]
2554 fn a_handoff_on_the_bare_class_cancels_nothing() {
2555 let dir = scratch("vocab-cancel-handoff");
2556 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2557 Vocabulary::in_frontend(&dir).run(&Emit::default());
2558 }
2559
2560 #[test]
2561 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2562 let dir = scratch("vocab-element-allowed");
2563 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2564 Vocabulary::in_frontend(&dir)
2565 .allow(Allow::element("select", "field", "box-shadow"))
2566 .run(&Emit::default());
2567
2568 let dir = scratch("vocab-element-allowed-stale");
2571 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2572 let err = std::panic::catch_unwind(|| {
2573 Vocabulary::in_frontend(&dir)
2574 .allow(Allow::element("select", "field", "box-shadow"))
2575 .run(&Emit::default());
2576 })
2577 .unwrap_err();
2578 let msg = err
2579 .downcast_ref::<String>()
2580 .expect("panic payload is a String");
2581 assert!(msg.contains("no longer collides"), "got: {msg}");
2582 }
2583
2584 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2586 let mut rungs = 0;
2587 let mut ladders = 0;
2588 for ladder in ["min-", "short-"] {
2589 let here = names
2590 .iter()
2591 .filter(|c| {
2592 c.strip_prefix(ladder)
2593 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2594 })
2595 .count();
2596 rungs += here;
2597 ladders += usize::from(here > 0);
2598 }
2599 (rungs, ladders)
2600 }
2601
2602 #[test]
2603 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2604 let dir = scratch("vocab-seal");
2605 let opts = Emit::default();
2606 let names = crate::vocabulary::names(&opts);
2607 let (rungs, ladders) = ladders(&names);
2608 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2609 let dead = names.len() - rungs + ladders;
2612 write(&dir, "index.html", "<div></div>\n");
2613 let markup = [dir.join("index.html")];
2614
2615 check_vocabulary_use(&markup, &opts, dead);
2616 assert!(
2617 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2618 "a vocabulary deader than the seal has to fail"
2619 );
2620 }
2621
2622 #[test]
2623 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2624 let opts = Emit::default();
2627 let names = crate::vocabulary::names(&opts);
2628 let (rungs, ladders) = ladders(&names);
2629 let dead = names.len() - rungs + ladders;
2630
2631 let dir = scratch("vocab-ladder");
2632 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2633 let one_rung = [dir.join("index.html")];
2634 check_vocabulary_use(&one_rung, &opts, dead - 2);
2636
2637 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2639 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2640 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2641 }
2642
2643 #[test]
2644 fn a_class_is_named_whole_on_both_sides() {
2645 assert!(haystack_names("class=\"row\"", "row"));
2646 assert!(!haystack_names("class=\"row-flex\"", "row"));
2647 assert!(!haystack_names("class=\"flex-row\"", "row"));
2648 assert!(haystack_names("el.classList.add('row')", "row"));
2649 }
2650
2651 #[test]
2652 fn range_syntax_and_units_are_read() {
2653 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2654 assert_eq!(
2655 media_widths(" (600px <= width < 840px)"),
2656 [Width::Px(600), Width::Px(840)]
2657 );
2658 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2659 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2660 assert_eq!(
2661 media_widths(" (max-width: 599.98px)"),
2662 [Width::Other("599.98px".into())]
2663 );
2664 assert_eq!(
2665 media_widths(" (width < 50vw)"),
2666 [Width::Other("50vw".into())]
2667 );
2668 assert!(media_widths(" (min-height: 600px)").is_empty());
2669 assert!(media_widths(" (max-device-width: 600px)").is_empty());
2670 assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
2671 }
2672
2673 #[test]
2674 fn every_size_class_condition_passes_its_own_check() {
2675 let dir = frontend("bp-classes");
2676 let mut css = String::new();
2677 for class in SizeClass::all() {
2678 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2679 }
2680 write(&dir, "css/styles.css", &css);
2681 write(
2682 &dir,
2683 "js/shell.js",
2684 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2685 );
2686 Breakpoints::in_frontend(&dir).run();
2687 }
2688
2689 #[test]
2690 #[should_panic(expected = "48em")]
2691 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2692 let dir = frontend("bp-em");
2693 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2694 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2695 }
2696
2697 #[test]
2698 #[should_panic(expected = "(width < 768px)")]
2699 fn a_stale_range_width_fails() {
2700 let dir = frontend("bp-range");
2701 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2702 Breakpoints::in_frontend(&dir).run();
2703 }
2704
2705 #[test]
2706 #[should_panic(expected = "599px")]
2707 fn the_old_inclusive_compact_bound_is_stale() {
2708 let dir = frontend("bp-599");
2709 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2710 Breakpoints::in_frontend(&dir).run();
2711 }
2712
2713 #[test]
2714 #[should_panic(expected = "(max-width: 600px)")]
2715 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2716 let dir = frontend("bp-600-inclusive");
2720 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2721 Breakpoints::in_frontend(&dir).run();
2722 }
2723
2724 #[test]
2725 #[should_panic(expected = "(width <= 600px)")]
2726 fn an_inclusive_range_upper_bound_is_stale() {
2727 let dir = frontend("bp-le-600");
2728 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2729 Breakpoints::in_frontend(&dir).run();
2730 }
2731
2732 #[test]
2733 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2734 let dir = frontend("bp-edges-ok");
2735 write(
2736 &dir,
2737 "css/styles.css",
2738 "@media (min-width: 600px) { }\n\
2739 @media (600px <= width) { }\n\
2740 @media (width >= 840px) { }\n",
2741 );
2742 Breakpoints::in_frontend(&dir).run();
2743 }
2744
2745 #[test]
2746 fn a_tuning_max_width_is_left_alone() {
2747 let dir = frontend("bp-tuning-max");
2750 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2751 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2752 }
2753
2754 #[test]
2755 fn a_call_in_script_is_not_a_feature() {
2756 assert!(js_widths("const w = Math.max(width, 600);").is_empty());
2757 assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
2758 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2759 }
2760
2761 #[test]
2762 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2763 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2764 let stripped = blank(css, Lang::Css);
2765 assert!(!stripped.contains("768"), "{stripped:?}");
2766 assert!(stripped.contains("a { }"));
2767 assert_eq!(stripped.len(), css.len());
2768 }
2769
2770 #[test]
2771 fn a_comment_opener_inside_a_string_is_text() {
2772 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2774 let src = blank(css, Lang::Css);
2775 assert_eq!(media_conditions(&src).len(), 1);
2776 assert!(src.contains("768"));
2777
2778 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2779 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2780 }
2781
2782 #[test]
2783 fn a_regex_literal_does_not_open_a_comment() {
2784 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2787 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2788 }
2789
2790 #[test]
2791 fn a_division_is_not_a_regex_literal() {
2792 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2794 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2795 }
2796
2797 #[test]
2798 fn a_css_bad_string_ends_at_a_newline() {
2799 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2802 let src = blank(css, Lang::Css);
2803 assert_eq!(media_conditions(&src).len(), 1);
2804 assert!(src.contains("768"));
2805 }
2806
2807 #[test]
2808 fn an_escaped_quote_does_not_open_a_string() {
2809 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2810 let src = blank(css, Lang::Css);
2811 assert_eq!(media_conditions(&src).len(), 1);
2812 }
2813
2814 #[test]
2815 fn an_unquoted_url_does_not_open_a_comment() {
2816 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2817 let src = blank(css, Lang::Css);
2818 assert_eq!(media_conditions(&src).len(), 1);
2819 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2821 @media (max-width: 768px) { }\n";
2822 let src = blank(css, Lang::Css);
2823 assert!(!src.contains("gone"), "{src:?}");
2824 assert_eq!(media_conditions(&src).len(), 1);
2825 }
2826
2827 #[test]
2828 fn a_leading_dot_length_is_read() {
2829 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2831 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2832 }
2833
2834 #[test]
2835 fn an_at_media_keyword_reads_in_any_case() {
2836 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2837 assert_eq!(conditions.len(), 1);
2838 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2839 }
2840
2841 #[test]
2842 fn a_width_nested_in_a_function_is_read() {
2843 assert_eq!(
2844 media_widths("(max-width: calc(768px))"),
2845 vec![Width::Px(768)]
2846 );
2847 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2850 }
2851
2852 #[test]
2853 fn a_newline_does_not_reset_an_open_group() {
2854 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2855 }
2856
2857 #[test]
2858 fn custom_media_and_import_carry_media_queries() {
2859 let css = "@custom-media --narrow (max-width: 768px);\n\
2860 @import url(x.css) (max-width: 768px);\n";
2861 let conditions = media_conditions(css);
2862 assert_eq!(conditions.len(), 2);
2863 for (_, condition) in conditions {
2864 assert!(
2865 media_widths(condition).contains(&Width::Px(768)),
2866 "unread: {condition:?}"
2867 );
2868 }
2869 }
2870
2871 #[test]
2872 fn a_query_in_a_script_comment_is_prose() {
2873 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2874 assert!(js_widths(&blank(js, Lang::Js)).is_empty());
2875 }
2876
2877 #[test]
2878 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2879 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2880 let src = blank(css, Lang::Css);
2881 assert_eq!(media_conditions(&src).len(), 1);
2882 }
2883
2884 #[test]
2885 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2886 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2889 let src = blank(css, Lang::Css);
2890 assert_eq!(src.len(), css.len());
2891 let at = css.find("@layer").unwrap();
2892 assert_eq!(&src[at..at + 6], "@layer");
2893 let dropped = drop_layers(css, &["reset"]);
2894 assert!(!dropped.contains("padding"), "{dropped:?}");
2895 }
2896
2897 #[test]
2898 fn a_dropped_layer_keeps_its_important_rules() {
2899 let css =
2903 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2904 let dropped = drop_layers(css, &["reset"]);
2905 assert!(!dropped.contains("padding"), "{dropped:?}");
2906 assert!(dropped.contains(".card"), "{dropped:?}");
2907 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2908 }
2909
2910 #[test]
2911 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2912 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2913 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2914 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2915 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2916 }
2917
2918 #[test]
2919 fn blanking_keeps_lines_and_offsets() {
2920 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2921 let src = blank(css, Lang::Css);
2922 assert_eq!(src.len(), css.len());
2923 assert_eq!(src.lines().count(), css.lines().count());
2924 }
2925
2926 #[test]
2927 fn both_quote_styles_read() {
2928 let want = Density::Touch.media_condition();
2929 for q in ['\'', '"'] {
2930 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2931 let found = touch_density_literals(&src);
2932 assert_eq!(found.len(), 1);
2933 assert_eq!(found[0].1, want);
2934 }
2935 }
2936
2937 #[test]
2938 fn a_file_list_skips_the_sheets_named_generated() {
2939 let dir = frontend("vocab-files");
2940 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2941 write(&dir, "css/app.css", "body { color: red; }\n");
2942 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2943 Vocabulary::files(&files)
2944 .generated(&["layout.css"])
2945 .run(&Emit::default());
2946 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2947 .unwrap_err();
2948 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2949 assert!(msg.contains("box-shadow"), "got: {msg}");
2950 }
2951}