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!(
1875 touch_density_literals(&src).is_empty(),
1876 "{:?}",
1877 touch_density_literals(&src)
1878 );
1879 }
1880
1881 #[test]
1882 fn whitespace_and_backtick_literals_read() {
1883 let want = Density::Touch.media_condition();
1884 for src in [
1885 format!("const {CONST_NAME}='{want}';\n"),
1886 format!("const {CONST_NAME} = `{want}`;\n"),
1887 format!("const {CONST_NAME} =\n '{want}';\n"),
1888 ] {
1889 let found = touch_density_literals(&src);
1890 assert_eq!(found.len(), 1, "src: {src:?}");
1891 assert_eq!(found[0].1, want, "src: {src:?}");
1892 }
1893 }
1894
1895 #[test]
1896 fn a_longer_identifier_is_not_the_const() {
1897 let want = Density::Touch.media_condition();
1899 for src in [
1900 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1901 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1902 ] {
1903 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1904 }
1905 }
1906
1907 #[test]
1908 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1909 fn a_commented_out_declaration_is_not_a_declaration() {
1910 let dir = scratch("commented-decl");
1913 write(
1914 &dir,
1915 "touch.js",
1916 &format!("// {}\n", declaring().trim_end()),
1917 );
1918 check_touch_density(&dir);
1919 }
1920
1921 #[test]
1922 fn a_sniff_named_in_a_comment_is_not_a_use() {
1923 let dir = scratch("sniff-comment");
1925 write(&dir, "touch.js", &declaring());
1926 write(
1927 &dir,
1928 "note.js",
1929 "// never read ontouchstart or maxTouchPoints here\n",
1930 );
1931 check_touch_density(&dir);
1932 }
1933
1934 #[test]
1935 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1936 fn a_wrong_literal_without_spaces_is_caught() {
1937 let dir = scratch("loose-drift");
1939 write(&dir, "touch.js", &declaring());
1940 write(
1941 &dir,
1942 "haptics.js",
1943 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1944 );
1945 check_touch_density(&dir);
1946 }
1947
1948 #[test]
1949 fn nested_files_are_read() {
1950 let dir = scratch("nested");
1953 write(&dir, "touch.js", &declaring());
1954 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1955 let files = js_files(&dir);
1956 assert_eq!(files.len(), 2);
1957 }
1958
1959 #[test]
1960 fn a_non_js_file_is_ignored() {
1961 let dir = scratch("nonjs");
1962 write(&dir, "touch.js", &declaring());
1963 write(&dir, "styles.css", "body { }\n");
1964 assert_eq!(js_files(&dir).len(), 1);
1965 }
1966
1967 fn frontend(name: &str) -> PathBuf {
1968 let dir = scratch(name);
1969 std::fs::create_dir_all(dir.join("css")).unwrap();
1970 std::fs::create_dir_all(dir.join("js")).unwrap();
1971 dir
1972 }
1973
1974 fn boundary() -> u16 {
1976 SizeClass::Medium.min_px()
1977 }
1978
1979 #[test]
1980 fn the_crates_own_boundaries_pass() {
1981 let dir = frontend("bp-ok");
1982 write(
1983 &dir,
1984 "css/styles.css",
1985 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1986 );
1987 Breakpoints::in_frontend(&dir).run();
1988 }
1989
1990 #[test]
1991 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1992 fn a_stale_css_width_fails() {
1993 let dir = frontend("bp-css");
1994 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1995 Breakpoints::in_frontend(&dir).run();
1996 }
1997
1998 #[test]
1999 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
2000 fn a_stale_js_width_fails() {
2001 let dir = frontend("bp-js");
2002 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
2003 Breakpoints::in_frontend(&dir).run();
2004 }
2005
2006 #[test]
2007 fn a_declared_tuning_width_passes() {
2008 let dir = frontend("bp-tuning");
2009 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
2010 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2011 }
2012
2013 #[test]
2014 fn a_container_query_on_a_shell_boundary_passes() {
2015 let dir = frontend("bp-container-ok");
2019 write(
2020 &dir,
2021 "css/styles.css",
2022 &format!("@container screen (width < {}px) {{ }}\n", boundary()),
2023 );
2024 Breakpoints::in_frontend(&dir).run();
2025 }
2026
2027 #[test]
2028 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
2029 fn a_stale_container_width_fails() {
2030 let dir = frontend("bp-container-stale");
2031 write(
2032 &dir,
2033 "css/styles.css",
2034 "@container screen (width < 768px) { }\n",
2035 );
2036 Breakpoints::in_frontend(&dir).run();
2037 }
2038
2039 #[test]
2040 fn a_container_inline_size_condition_is_not_a_width_boundary() {
2041 let dir = frontend("bp-container-inline");
2044 write(
2045 &dir,
2046 "css/styles.css",
2047 "@container tab-strip (inline-size < 36rem) { }\n",
2048 );
2049 Breakpoints::in_frontend(&dir).run();
2050 }
2051
2052 #[test]
2053 fn a_width_in_a_comment_is_prose() {
2054 let dir = frontend("bp-comment");
2057 write(
2058 &dir,
2059 "css/styles.css",
2060 "/* was @media (max-width: 768px) until the size classes landed */\n",
2061 );
2062 Breakpoints::in_frontend(&dir).run();
2063 }
2064
2065 #[test]
2066 fn an_unparenthesized_width_is_not_a_breakpoint() {
2067 let dir = frontend("bp-inline");
2071 write(
2072 &dir,
2073 "js/style.js",
2074 "el.style.cssText = 'max-width: 320px; display: block';\n",
2075 );
2076 Breakpoints::in_frontend(&dir).run();
2077 }
2078
2079 #[test]
2080 fn nested_css_is_read() {
2081 let dir = frontend("bp-nested");
2084 write(
2085 &dir,
2086 "css/screens/detail.css",
2087 "@media (max-width: 768px) { }\n",
2088 );
2089 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
2090 assert!(found.is_err(), "a nested stylesheet must be scanned");
2091 }
2092
2093 #[test]
2094 fn a_css_only_frontend_scans_without_a_js_directory() {
2095 let dir = scratch("bp-css-only");
2098 write(
2099 &dir,
2100 "css/styles.css",
2101 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
2102 );
2103 Breakpoints::in_frontend(&dir).run();
2104 }
2105
2106 #[test]
2107 fn a_named_list_is_checked() {
2108 let dir = frontend("bp-list");
2109 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
2110 let listed = dir.join("css/style.css");
2111 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
2112 let msg = err.downcast_ref::<String>().expect("String payload");
2113 assert!(msg.contains("style.css:1"), "got: {msg}");
2114 }
2115
2116 #[test]
2117 #[should_panic(expected = "read ")]
2118 fn a_listed_file_that_is_gone_fails() {
2119 let dir = frontend("bp-missing");
2122 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
2123 }
2124
2125 #[test]
2126 fn a_listed_js_file_is_parsed_as_script() {
2127 let dir = frontend("bp-list-js");
2130 write(
2131 &dir,
2132 "js/style.js",
2133 "el.style.cssText = 'max-width: 320px';\n",
2134 );
2135 Breakpoints::files(&[dir.join("js/style.js")]).run();
2136 }
2137
2138 #[test]
2139 fn the_error_names_the_file_and_line() {
2140 let dir = frontend("bp-message");
2141 write(
2142 &dir,
2143 "css/styles.css",
2144 "body { }\n@media (max-width: 768px) { }\n",
2145 );
2146 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
2147 let msg = err
2148 .downcast_ref::<String>()
2149 .expect("panic payload is a String");
2150 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
2151 }
2152
2153 #[test]
2154 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
2155 let upstream = ".tile-set { gap: 4px; }\n";
2158 let dir = scratch("vocab-upstream");
2159 write(&dir, "css/quasi.css", upstream);
2160 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
2161 let err = std::panic::catch_unwind(|| {
2162 Vocabulary::in_frontend(&dir)
2163 .upstream(&[upstream])
2164 .run(&Emit::default());
2165 })
2166 .unwrap_err();
2167 let msg = err
2168 .downcast_ref::<String>()
2169 .expect("panic payload is a String");
2170 assert!(
2171 msg.contains("css/styles.css .tile-set { gap }"),
2172 "got: {msg}"
2173 );
2174 assert!(
2175 !msg.contains("css/quasi.css"),
2176 "the served copy clashed with itself: {msg}"
2177 );
2178
2179 Vocabulary::in_frontend(&dir).run(&Emit::default());
2182 }
2183
2184 #[test]
2185 fn a_rule_restating_a_generated_class_fails_and_names_it() {
2186 let dir = scratch("vocab-clash");
2187 write(
2190 &dir,
2191 "css/styles.css",
2192 "body { color: red; }\n.card { box-shadow: none; }\n",
2193 );
2194 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2195 .unwrap_err();
2196 let msg = err
2197 .downcast_ref::<String>()
2198 .expect("panic payload is a String");
2199 assert!(msg.contains(".card"), "got: {msg}");
2200 assert!(msg.contains("box-shadow"), "got: {msg}");
2201 assert!(msg.contains("css/styles.css"), "got: {msg}");
2202 }
2203
2204 #[test]
2205 fn an_app_class_of_its_own_is_left_alone() {
2206 let dir = scratch("vocab-clean");
2207 write(
2208 &dir,
2209 "css/styles.css",
2210 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
2211 );
2212 Vocabulary::in_frontend(&dir).run(&Emit::default());
2213 }
2214
2215 #[test]
2216 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
2217 let dir = scratch("vocab-generated");
2218 let opts = Emit::default();
2219 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
2220 Vocabulary::in_frontend(&dir)
2223 .generated(&["layout.css"])
2224 .run(&opts);
2225 }
2226
2227 #[test]
2228 fn a_prefixed_app_is_checked_against_its_own_prefix() {
2229 let dir = scratch("vocab-prefix");
2230 let opts = Emit::new().class_prefix("mo-");
2231 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
2234 Vocabulary::in_frontend(&dir).run(&opts);
2235
2236 let dir = scratch("vocab-prefix-clash");
2237 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
2238 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
2239 }
2240
2241 #[test]
2242 fn a_class_shared_without_a_shared_property_is_left_alone() {
2243 let dir = scratch("vocab-additive");
2244 write(
2247 &dir,
2248 "css/styles.css",
2249 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
2250 );
2251 Vocabulary::in_frontend(&dir).run(&Emit::default());
2252 }
2253
2254 #[test]
2255 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
2256 let dir = scratch("vocab-allowed");
2257 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
2258 Vocabulary::in_frontend(&dir)
2259 .allow(Allow::class("badge", "padding"))
2260 .run(&Emit::default());
2261
2262 let dir = scratch("vocab-allowed-stale");
2265 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
2266 let err = std::panic::catch_unwind(|| {
2267 Vocabulary::in_frontend(&dir)
2268 .allow(Allow::class("badge", "padding"))
2269 .run(&Emit::default());
2270 })
2271 .unwrap_err();
2272 let msg = err
2273 .downcast_ref::<String>()
2274 .expect("panic payload is a String");
2275 assert!(msg.contains("no longer collides"), "got: {msg}");
2276 }
2277
2278 #[test]
2279 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2280 let dir = scratch("vocab-element");
2281 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2285 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2286 .unwrap_err();
2287 let msg = err
2288 .downcast_ref::<String>()
2289 .expect("panic payload is a String");
2290 assert!(msg.contains("select {"), "got: {msg}");
2291 assert!(msg.contains(".field"), "got: {msg}");
2292 assert!(msg.contains("box-shadow"), "got: {msg}");
2293 assert!(msg.contains("css/styles.css"), "got: {msg}");
2294 }
2295
2296 #[test]
2297 fn a_universal_reset_after_the_design_system_is_caught() {
2298 let dir = scratch("vocab-universal");
2302 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2303 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2304 .unwrap_err();
2305 let msg = err
2306 .downcast_ref::<String>()
2307 .expect("panic payload is a String");
2308 assert!(msg.contains("* {"), "got: {msg}");
2309 assert!(msg.contains("padding"), "got: {msg}");
2310 }
2311
2312 #[test]
2313 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2314 let dir = scratch("vocab-universal-beneath");
2315 write(
2316 &dir,
2317 "css/styles.css",
2318 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2319 );
2320 Vocabulary::in_frontend(&dir)
2321 .beneath(&["reset"])
2322 .run(&Emit::default());
2323 }
2324
2325 #[test]
2326 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2327 let dir = scratch("vocab-important-beneath");
2328 write(
2333 &dir,
2334 "css/styles.css",
2335 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2336 );
2337 let err = std::panic::catch_unwind(|| {
2338 Vocabulary::in_frontend(&dir)
2339 .beneath(&["reset"])
2340 .run(&Emit::default());
2341 })
2342 .unwrap_err();
2343 let msg = err.downcast_ref::<String>().expect("a String payload");
2344 assert!(msg.contains("box-shadow"), "got: {msg}");
2345 }
2346
2347 #[test]
2348 fn a_layer_not_named_beneath_is_still_read() {
2349 let dir = scratch("vocab-universal-other-layer");
2350 write(
2351 &dir,
2352 "css/styles.css",
2353 "@layer base {\n * { padding: 0; }\n}\n",
2354 );
2355 let err = std::panic::catch_unwind(|| {
2356 Vocabulary::in_frontend(&dir)
2357 .beneath(&["reset"])
2358 .run(&Emit::default());
2359 })
2360 .unwrap_err();
2361 let msg = err.downcast_ref::<String>().expect("a String payload");
2362 assert!(msg.contains("* {"), "got: {msg}");
2363 }
2364
2365 #[test]
2366 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2367 let dir = scratch("vocab-universal-box");
2368 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2369 Vocabulary::in_frontend(&dir).run(&Emit::default());
2370 }
2371
2372 #[test]
2373 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2374 let dir = scratch("vocab-element-handoff");
2375 write(
2380 &dir,
2381 "css/styles.css",
2382 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2383 );
2384 Vocabulary::in_frontend(&dir).run(&Emit::default());
2385 }
2386
2387 #[test]
2388 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2389 let dir = scratch("vocab-element-spoken-for");
2390 write(
2395 &dir,
2396 "css/styles.css",
2397 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2398 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2399 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2400 );
2401 Vocabulary::in_frontend(&dir)
2402 .allow(Allow::class("field", "box-shadow"))
2403 .run(&Emit::default());
2404 }
2405
2406 #[test]
2407 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2408 let dir = scratch("vocab-element-weak-handoff");
2409 write(
2413 &dir,
2414 "css/styles.css",
2415 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2416 );
2417 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2418 .unwrap_err();
2419 let msg = err
2420 .downcast_ref::<String>()
2421 .expect("panic payload is a String");
2422 assert!(msg.contains(".field"), "got: {msg}");
2423
2424 let dir = scratch("vocab-element-strong-handoff");
2426 write(
2427 &dir,
2428 "css/styles.css",
2429 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2430 );
2431 Vocabulary::in_frontend(&dir).run(&Emit::default());
2432 }
2433
2434 #[test]
2435 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2436 let dir = scratch("vocab-element-scoped");
2437 write(
2441 &dir,
2442 "css/styles.css",
2443 ".wizard select { box-shadow: none; }\n",
2444 );
2445 Vocabulary::in_frontend(&dir).run(&Emit::default());
2446 }
2447
2448 #[test]
2449 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2450 let dir = scratch("vocab-element-unpaired");
2451 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2454 Vocabulary::in_frontend(&dir).run(&Emit::default());
2455 }
2456
2457 #[test]
2458 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2459 let dir = scratch("vocab-cancel-tone");
2464 write(
2465 &dir,
2466 "css/styles.css",
2467 ".button { background: var(--surface-raised); }\n",
2468 );
2469 let err = std::panic::catch_unwind(|| {
2470 Vocabulary::in_frontend(&dir)
2471 .allow(Allow::class("button", "background"))
2472 .run(&Emit::default());
2473 })
2474 .unwrap_err();
2475 let msg = err
2476 .downcast_ref::<String>()
2477 .expect("panic payload is a String");
2478 assert!(
2479 msg.contains(".button { background } cancels"),
2480 "got: {msg}"
2481 );
2482 assert!(
2483 msg.contains(".button[data-tone=\"danger\"] { background }"),
2484 "the message names the variant it cancels: {msg}"
2485 );
2486 assert!(msg.contains("restate"), "and the remedy: {msg}");
2487 }
2488
2489 #[test]
2490 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2491 let opts = Emit::default();
2494 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2495 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2496 for (arm, properties) in &variants["button"] {
2497 if properties.contains("background") {
2498 let loose = arm.replace('"', "'").replace(',', " , ");
2499 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2500 }
2501 }
2502 let dir = scratch("vocab-cancel-restated");
2503 write(&dir, "css/styles.css", &css);
2504 Vocabulary::in_frontend(&dir)
2505 .allow(Allow::class("button", "background"))
2506 .run(&opts);
2507 }
2508
2509 #[test]
2510 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2511 let dir = scratch("vocab-cancel-scoped");
2514 write(
2515 &dir,
2516 "css/styles.css",
2517 ".toolbar .button { color: var(--content); }\n\
2518 .button.quiet { color: var(--content); }\n",
2519 );
2520 Vocabulary::in_frontend(&dir)
2523 .allow(Allow::class("button", "color"))
2524 .run(&Emit::default());
2525 }
2526
2527 #[test]
2528 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2529 let dir = scratch("vocab-cancel-own-order");
2533 write(
2534 &dir,
2535 "css/quasi.css",
2536 "@layer reset, quasi, makeover;\n\
2537 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2538 );
2539 Vocabulary::in_frontend(&dir)
2540 .allow(Allow::class("button", "cursor"))
2541 .run(&Emit::default());
2542
2543 let dir = scratch("vocab-cancel-own-order-above");
2545 write(
2546 &dir,
2547 "css/app.css",
2548 "@layer reset, makeover, app;\n\
2549 @layer app {\n .button { cursor: pointer; }\n}\n",
2550 );
2551 let err = std::panic::catch_unwind(|| {
2552 Vocabulary::in_frontend(&dir)
2553 .allow(Allow::class("button", "cursor"))
2554 .run(&Emit::default());
2555 })
2556 .unwrap_err();
2557 let msg = err
2558 .downcast_ref::<String>()
2559 .expect("panic payload is a String");
2560 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2561 }
2562
2563 #[test]
2564 fn a_handoff_on_the_bare_class_cancels_nothing() {
2565 let dir = scratch("vocab-cancel-handoff");
2566 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2567 Vocabulary::in_frontend(&dir).run(&Emit::default());
2568 }
2569
2570 #[test]
2571 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2572 let dir = scratch("vocab-element-allowed");
2573 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2574 Vocabulary::in_frontend(&dir)
2575 .allow(Allow::element("select", "field", "box-shadow"))
2576 .run(&Emit::default());
2577
2578 let dir = scratch("vocab-element-allowed-stale");
2581 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2582 let err = std::panic::catch_unwind(|| {
2583 Vocabulary::in_frontend(&dir)
2584 .allow(Allow::element("select", "field", "box-shadow"))
2585 .run(&Emit::default());
2586 })
2587 .unwrap_err();
2588 let msg = err
2589 .downcast_ref::<String>()
2590 .expect("panic payload is a String");
2591 assert!(msg.contains("no longer collides"), "got: {msg}");
2592 }
2593
2594 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2596 let mut rungs = 0;
2597 let mut ladders = 0;
2598 for ladder in [
2599 "min-",
2600 "short-",
2601 "stacks-",
2602 "drop-optional-",
2603 "drop-secondary-",
2604 ] {
2605 let here = names
2606 .iter()
2607 .filter(|c| {
2608 c.strip_prefix(ladder)
2609 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2610 })
2611 .count();
2612 rungs += here;
2613 ladders += usize::from(here > 0);
2614 }
2615 (rungs, ladders)
2616 }
2617
2618 #[test]
2619 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2620 let dir = scratch("vocab-seal");
2621 let opts = Emit::default();
2622 let names = crate::vocabulary::names(&opts);
2623 let (rungs, ladders) = ladders(&names);
2624 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2625 let dead = names.len() - rungs + ladders;
2628 write(&dir, "index.html", "<div></div>\n");
2629 let markup = [dir.join("index.html")];
2630
2631 check_vocabulary_use(&markup, &opts, dead);
2632 assert!(
2633 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2634 "a vocabulary deader than the seal has to fail"
2635 );
2636 }
2637
2638 #[test]
2639 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2640 let opts = Emit::default();
2643 let names = crate::vocabulary::names(&opts);
2644 let (rungs, ladders) = ladders(&names);
2645 let dead = names.len() - rungs + ladders;
2646
2647 let dir = scratch("vocab-ladder");
2648 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2649 let one_rung = [dir.join("index.html")];
2650 check_vocabulary_use(&one_rung, &opts, dead - 2);
2652
2653 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2655 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2656 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2657 }
2658
2659 #[test]
2660 fn a_class_is_named_whole_on_both_sides() {
2661 assert!(haystack_names("class=\"row\"", "row"));
2662 assert!(!haystack_names("class=\"row-flex\"", "row"));
2663 assert!(!haystack_names("class=\"flex-row\"", "row"));
2664 assert!(haystack_names("el.classList.add('row')", "row"));
2665 }
2666
2667 #[test]
2668 fn range_syntax_and_units_are_read() {
2669 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2670 assert_eq!(
2671 media_widths(" (600px <= width < 840px)"),
2672 [Width::Px(600), Width::Px(840)]
2673 );
2674 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2675 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2676 assert_eq!(
2677 media_widths(" (max-width: 599.98px)"),
2678 [Width::Other("599.98px".into())]
2679 );
2680 assert_eq!(
2681 media_widths(" (width < 50vw)"),
2682 [Width::Other("50vw".into())]
2683 );
2684 let found = media_widths(" (min-height: 600px)");
2685 assert!(found.is_empty(), "{found:?}");
2686 let found = media_widths(" (max-device-width: 600px)");
2687 assert!(found.is_empty(), "{found:?}");
2688 let found = media_widths(" (hover: hover) and (pointer: fine)");
2689 assert!(found.is_empty(), "{found:?}");
2690 }
2691
2692 #[test]
2693 fn every_size_class_condition_passes_its_own_check() {
2694 let dir = frontend("bp-classes");
2695 let mut css = String::new();
2696 for class in SizeClass::all() {
2697 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2698 }
2699 write(&dir, "css/styles.css", &css);
2700 write(
2701 &dir,
2702 "js/shell.js",
2703 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2704 );
2705 Breakpoints::in_frontend(&dir).run();
2706 }
2707
2708 #[test]
2709 #[should_panic(expected = "48em")]
2710 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2711 let dir = frontend("bp-em");
2712 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2713 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2714 }
2715
2716 #[test]
2717 #[should_panic(expected = "(width < 768px)")]
2718 fn a_stale_range_width_fails() {
2719 let dir = frontend("bp-range");
2720 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2721 Breakpoints::in_frontend(&dir).run();
2722 }
2723
2724 #[test]
2725 #[should_panic(expected = "599px")]
2726 fn the_old_inclusive_compact_bound_is_stale() {
2727 let dir = frontend("bp-599");
2728 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2729 Breakpoints::in_frontend(&dir).run();
2730 }
2731
2732 #[test]
2733 #[should_panic(expected = "(max-width: 600px)")]
2734 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2735 let dir = frontend("bp-600-inclusive");
2739 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2740 Breakpoints::in_frontend(&dir).run();
2741 }
2742
2743 #[test]
2744 #[should_panic(expected = "(width <= 600px)")]
2745 fn an_inclusive_range_upper_bound_is_stale() {
2746 let dir = frontend("bp-le-600");
2747 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2748 Breakpoints::in_frontend(&dir).run();
2749 }
2750
2751 #[test]
2752 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2753 let dir = frontend("bp-edges-ok");
2754 write(
2755 &dir,
2756 "css/styles.css",
2757 "@media (min-width: 600px) { }\n\
2758 @media (600px <= width) { }\n\
2759 @media (width >= 840px) { }\n",
2760 );
2761 Breakpoints::in_frontend(&dir).run();
2762 }
2763
2764 #[test]
2765 fn a_tuning_max_width_is_left_alone() {
2766 let dir = frontend("bp-tuning-max");
2769 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2770 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2771 }
2772
2773 #[test]
2774 fn a_call_in_script_is_not_a_feature() {
2775 let found = js_widths("const w = Math.max(width, 600);");
2776 assert!(found.is_empty(), "{found:?}");
2777 let found = js_widths("el.style.cssText = 'max-width: 320px';");
2778 assert!(found.is_empty(), "{found:?}");
2779 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2780 }
2781
2782 #[test]
2783 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2784 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2785 let stripped = blank(css, Lang::Css);
2786 assert!(!stripped.contains("768"), "{stripped:?}");
2787 assert!(stripped.contains("a { }"));
2788 assert_eq!(stripped.len(), css.len());
2789 }
2790
2791 #[test]
2792 fn a_comment_opener_inside_a_string_is_text() {
2793 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2795 let src = blank(css, Lang::Css);
2796 assert_eq!(media_conditions(&src).len(), 1);
2797 assert!(src.contains("768"));
2798
2799 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2800 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2801 }
2802
2803 #[test]
2804 fn a_regex_literal_does_not_open_a_comment() {
2805 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2808 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2809 }
2810
2811 #[test]
2812 fn a_division_is_not_a_regex_literal() {
2813 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2815 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2816 }
2817
2818 #[test]
2819 fn a_css_bad_string_ends_at_a_newline() {
2820 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2823 let src = blank(css, Lang::Css);
2824 assert_eq!(media_conditions(&src).len(), 1);
2825 assert!(src.contains("768"));
2826 }
2827
2828 #[test]
2829 fn an_escaped_quote_does_not_open_a_string() {
2830 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2831 let src = blank(css, Lang::Css);
2832 assert_eq!(media_conditions(&src).len(), 1);
2833 }
2834
2835 #[test]
2836 fn an_unquoted_url_does_not_open_a_comment() {
2837 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2838 let src = blank(css, Lang::Css);
2839 assert_eq!(media_conditions(&src).len(), 1);
2840 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2842 @media (max-width: 768px) { }\n";
2843 let src = blank(css, Lang::Css);
2844 assert!(!src.contains("gone"), "{src:?}");
2845 assert_eq!(media_conditions(&src).len(), 1);
2846 }
2847
2848 #[test]
2849 fn a_leading_dot_length_is_read() {
2850 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2852 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2853 }
2854
2855 #[test]
2856 fn an_at_media_keyword_reads_in_any_case() {
2857 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2858 assert_eq!(conditions.len(), 1);
2859 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2860 }
2861
2862 #[test]
2863 fn a_width_nested_in_a_function_is_read() {
2864 assert_eq!(
2865 media_widths("(max-width: calc(768px))"),
2866 vec![Width::Px(768)]
2867 );
2868 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2871 }
2872
2873 #[test]
2874 fn a_newline_does_not_reset_an_open_group() {
2875 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2876 }
2877
2878 #[test]
2879 fn custom_media_and_import_carry_media_queries() {
2880 let css = "@custom-media --narrow (max-width: 768px);\n\
2881 @import url(x.css) (max-width: 768px);\n";
2882 let conditions = media_conditions(css);
2883 assert_eq!(conditions.len(), 2);
2884 for (_, condition) in conditions {
2885 assert!(
2886 media_widths(condition).contains(&Width::Px(768)),
2887 "unread: {condition:?}"
2888 );
2889 }
2890 }
2891
2892 #[test]
2893 fn a_query_in_a_script_comment_is_prose() {
2894 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2895 let found = js_widths(&blank(js, Lang::Js));
2896 assert!(found.is_empty(), "{found:?}");
2897 }
2898
2899 #[test]
2900 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2901 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2902 let src = blank(css, Lang::Css);
2903 assert_eq!(media_conditions(&src).len(), 1);
2904 }
2905
2906 #[test]
2907 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2908 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2911 let src = blank(css, Lang::Css);
2912 assert_eq!(src.len(), css.len());
2913 let at = css.find("@layer").unwrap();
2914 assert_eq!(&src[at..at + 6], "@layer");
2915 let dropped = drop_layers(css, &["reset"]);
2916 assert!(!dropped.contains("padding"), "{dropped:?}");
2917 }
2918
2919 #[test]
2920 fn a_dropped_layer_keeps_its_important_rules() {
2921 let css =
2925 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2926 let dropped = drop_layers(css, &["reset"]);
2927 assert!(!dropped.contains("padding"), "{dropped:?}");
2928 assert!(dropped.contains(".card"), "{dropped:?}");
2929 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2930 }
2931
2932 #[test]
2933 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2934 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2935 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2936 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2937 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2938 }
2939
2940 #[test]
2941 fn blanking_keeps_lines_and_offsets() {
2942 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2943 let src = blank(css, Lang::Css);
2944 assert_eq!(src.len(), css.len());
2945 assert_eq!(src.lines().count(), css.lines().count());
2946 }
2947
2948 #[test]
2949 fn both_quote_styles_read() {
2950 let want = Density::Touch.media_condition();
2951 for q in ['\'', '"'] {
2952 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2953 let found = touch_density_literals(&src);
2954 assert_eq!(found.len(), 1);
2955 assert_eq!(found[0].1, want);
2956 }
2957 }
2958
2959 #[test]
2960 fn a_file_list_skips_the_sheets_named_generated() {
2961 let dir = frontend("vocab-files");
2962 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2963 write(&dir, "css/app.css", "body { color: red; }\n");
2964 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2965 Vocabulary::files(&files)
2966 .generated(&["layout.css"])
2967 .run(&Emit::default());
2968 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2969 .unwrap_err();
2970 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2971 assert!(msg.contains("box-shadow"), "got: {msg}");
2972 }
2973}