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)]
241#[must_use = "a check does nothing until it is run"]
242pub struct Breakpoints {
243 source: Source,
244 tuning: Vec<u16>,
245}
246
247impl Breakpoints {
248 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
251 Self {
252 source: Source::Frontend(frontend.as_ref().to_path_buf()),
253 tuning: Vec::new(),
254 }
255 }
256
257 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
267 Self {
268 source: Source::files(paths),
269 tuning: Vec::new(),
270 }
271 }
272
273 pub fn tuning(mut self, widths: &[u16]) -> Self {
283 self.tuning.extend_from_slice(widths);
284 self
285 }
286
287 pub fn run(&self) {
297 match &self.source {
298 Source::Frontend(frontend) => {
299 let mut files = files_with_extension(&frontend.join("css"), "css");
300 let js = frontend.join("js");
303 if js.is_dir() {
304 files.extend(js_files(&js));
305 }
306 check_paths(&files, &self.tuning, Some(frontend));
307 println!("cargo:rerun-if-changed={}", frontend.join("css").display());
309 println!("cargo:rerun-if-changed={}", js.display());
310 }
311 Source::Files(paths) => check_paths(paths, &self.tuning, None),
312 }
313 }
314}
315
316fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
318 let allowed = allowed_widths(tuning_widths);
319 let edges = size_class_widths();
320 let mut stale: Vec<String> = Vec::new();
321
322 for path in paths {
323 let raw = std::fs::read_to_string(path)
324 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
325 let name = match root {
326 Some(root) => display_name(root, path),
327 None => path.display().to_string(),
328 };
329
330 if path.extension().is_some_and(|x| x == "js") {
331 for (offset, bound) in js_widths(&blank(&raw, Lang::Js)) {
333 if !bound.is_allowed(&allowed, &edges) {
334 stale.push(format!(
335 " {name}:{} ({})",
336 line_of(&raw, offset),
337 bound.width
338 ));
339 }
340 }
341 continue;
342 }
343
344 let src = blank(&raw, Lang::Css);
347 for (offset, condition) in media_conditions(&src) {
348 for bound in media_bounds(condition) {
349 if !bound.is_allowed(&allowed, &edges) {
350 stale.push(format!(
351 " {name}:{} @media{condition} ({})",
352 line_of(&src, offset),
353 bound.width
354 ));
355 }
356 }
357 }
358 }
359
360 assert!(
361 stale.is_empty(),
362 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
363 Allowed: {allowed:?}\n\
364 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
365 Stale:\n{}\n\n\
366 Size classes are written in range syntax, `(width < 600px)`, because\n\
367 `(max-width: 599px)` leaves 599.5px in no class. If a size class moved,\n\
368 update these to match. If one of these is a new\n\
369 tuning width inside the wide shell rather than a shell boundary, add it\n\
370 to the caller's tuning list with a note saying what it tunes.\n\n\
371 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
372 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
373 clamp(). A threshold is for what appears and disappears.",
374 allowed
375 .iter()
376 .filter(|px| !tuning_widths.contains(px))
377 .collect::<Vec<_>>(),
378 stale.join("\n")
379 );
380
381 for path in paths {
382 println!("cargo:rerun-if-changed={}", path.display());
383 }
384}
385
386fn display_name(frontend: &Path, path: &Path) -> String {
388 path.strip_prefix(frontend)
389 .unwrap_or(path)
390 .display()
391 .to_string()
392}
393
394fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
400 let mut widths = size_class_widths();
401 widths.extend_from_slice(tuning_widths);
402 widths.sort_unstable();
403 widths.dedup();
404 widths
405}
406
407fn size_class_widths() -> Vec<u16> {
412 let mut widths: Vec<u16> = SizeClass::all()
413 .iter()
414 .flat_map(|c| media_widths(&c.media_condition()))
415 .map(|width| match width {
416 Width::Px(px) => px,
417 Width::Other(raw) => {
418 panic!("makeover-geometry wrote a size-class width this check cannot read: {raw}")
419 }
420 })
421 .collect();
422 widths.sort_unstable();
423 widths.dedup();
424 widths
425}
426
427#[derive(Debug, Clone, PartialEq, Eq)]
429enum Width {
430 Px(u16),
433 Other(String),
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
441struct Bound {
442 width: Width,
443 direction: Direction,
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449enum Direction {
450 Edge,
453 Straddles,
456}
457
458impl Bound {
459 fn is_allowed(&self, allowed: &[u16], edges: &[u16]) -> bool {
464 match &self.width {
465 Width::Px(px) => {
466 allowed.contains(px)
467 && !(edges.contains(px) && self.direction == Direction::Straddles)
468 }
469 Width::Other(_) => false,
470 }
471 }
472}
473
474impl std::fmt::Display for Width {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 match self {
477 Self::Px(px) => write!(f, "{px}px"),
478 Self::Other(raw) => f.write_str(raw),
479 }
480 }
481}
482
483fn media_widths(condition: &str) -> Vec<Width> {
485 media_bounds(condition)
486 .into_iter()
487 .map(|bound| bound.width)
488 .collect()
489}
490
491fn media_bounds(condition: &str) -> Vec<Bound> {
501 width_groups(condition)
502 .into_iter()
503 .flat_map(|(_, group)| lengths(group))
504 .collect()
505}
506
507fn width_groups(src: &str) -> Vec<(usize, &str)> {
511 let mut out: Vec<(usize, &str)> = Vec::new();
512 let mut stack: Vec<usize> = Vec::new();
513 for (i, c) in src.char_indices() {
514 match c {
515 '(' => stack.push(i),
516 ')' => {
517 if let Some(start) = stack.pop() {
518 let inner = &src[start + 1..i];
519 let encloses_read = out.iter().any(|&(inner_start, _)| inner_start > start);
524 if names_width(inner) && !encloses_read {
525 out.push((start, inner));
526 }
527 }
528 }
529 _ => {}
530 }
531 }
532 out
533}
534
535fn names_width(inner: &str) -> bool {
539 width_token_at(inner).is_some()
540}
541
542fn width_token_at(inner: &str) -> Option<usize> {
546 inner.match_indices("width").find_map(|(at, _)| {
547 let before = &inner[..at];
548 let prefix_ok = before.is_empty()
549 || before.ends_with("min-")
550 || before.ends_with("max-")
551 || before
552 .chars()
553 .next_back()
554 .is_some_and(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_');
555 let after = inner[at + "width".len()..].chars().next();
556 (prefix_ok && after.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
557 .then_some(at)
558 })
559}
560
561fn lengths(group: &str) -> Vec<Bound> {
564 let bytes = group.as_bytes();
565 let width_at = width_token_at(group);
566 let mut out = Vec::new();
567 let mut i = 0;
568 while i < bytes.len() {
569 let prev_ok = i == 0
572 || !(bytes[i - 1].is_ascii_alphanumeric()
573 || bytes[i - 1] == b'-'
574 || bytes[i - 1] == b'.');
575 let starts_number = prev_ok
576 && (bytes[i].is_ascii_digit()
577 || (bytes[i] == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)));
578 if !starts_number {
579 i += 1;
580 continue;
581 }
582 let start = i;
583 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
584 i += 1;
585 }
586 let number = &group[start..i];
587 let unit_start = i;
588 while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
589 i += 1;
590 }
591 let unit = &group[unit_start..i];
592 if unit.is_empty() {
593 continue;
594 }
595 let raw = &group[start..i];
596 let scale = match unit {
597 "px" => Some(1.0),
598 "em" | "rem" => Some(16.0),
599 _ => None,
600 };
601 let px = scale.and_then(|scale| number.parse::<f64>().ok().map(|n| n * scale));
602 let width = match px {
603 Some(px) if px.fract() == 0.0 && px >= 0.0 && px <= f64::from(u16::MAX) => {
604 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
606 Width::Px(px as u16)
607 }
608 _ => Width::Other(raw.to_string()),
609 };
610 out.push(Bound {
611 width,
612 direction: direction_of(group, start, i, width_at),
613 });
614 }
615 out
616}
617
618fn direction_of(group: &str, start: usize, end: usize, width_at: Option<usize>) -> Direction {
627 let Some(width_at) = width_at else {
628 return Direction::Edge;
629 };
630 let width_end = width_at + "width".len();
631 if start >= width_end {
632 let op = &group[width_end..start];
634 if op.contains("<=") {
635 Direction::Straddles
636 } else if op.contains(">=") || op.contains('<') {
637 Direction::Edge
638 } else if op.contains('>') {
639 Direction::Straddles
640 } else if group[..width_at].ends_with("min-") {
641 Direction::Edge
642 } else {
643 Direction::Straddles
645 }
646 } else {
647 let op = &group[end..width_at];
649 if op.contains("<=") || op.contains('>') {
650 Direction::Edge
651 } else {
652 Direction::Straddles
653 }
654 }
655}
656
657fn media_conditions(css: &str) -> Vec<(usize, &str)> {
662 let lower = css.to_ascii_lowercase();
663 let mut out = Vec::new();
664 let mut at = 0;
665 while let Some(rel) = lower[at..].find('@') {
666 let start = at + rel;
667 let kw_start = start + 1;
668 let kw_end = css[kw_start..]
669 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
670 .map_or(css.len(), |i| kw_start + i);
671 let terminator = match &lower[kw_start..kw_end] {
672 "media" => '{',
673 "custom-media" | "import" => ';',
674 _ => {
675 at = start + 1;
676 continue;
677 }
678 };
679 match css[kw_end..].find(terminator) {
680 Some(j) => {
681 out.push((start, &css[kw_end..kw_end + j]));
682 at = kw_end + j + 1;
683 }
684 None => break,
685 }
686 }
687 out
688}
689
690fn js_widths(src: &str) -> Vec<(usize, Bound)> {
698 width_groups(src)
699 .into_iter()
700 .filter(|(_, group)| is_feature(group))
701 .flat_map(|(offset, group)| lengths(group).into_iter().map(move |b| (offset, b)))
702 .collect()
703}
704
705fn is_feature(group: &str) -> bool {
709 let group = group.trim();
710 let colon = ["min-width", "max-width", "width"].iter().any(|name| {
711 group
712 .strip_prefix(name)
713 .is_some_and(|rest| rest.trim_start().starts_with(':'))
714 });
715 colon || group.contains(['<', '>'])
716}
717
718#[derive(Clone, Copy, PartialEq, Eq)]
720enum Lang {
721 Css,
724 Js,
728}
729
730fn blank(src: &str, lang: Lang) -> String {
738 #[derive(Clone, Copy)]
739 enum State {
740 Code,
741 Block,
742 Line,
743 Quoted(char),
744 Regex,
745 RawUrl,
746 }
747 let blank_into = |out: &mut String, c: char| {
750 if c == '\n' {
751 out.push('\n');
752 } else {
753 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
754 }
755 };
756 let chars: Vec<char> = src.chars().collect();
757 let mut out = String::with_capacity(src.len());
758 let mut state = State::Code;
759 let mut prev: Option<char> = None;
762 let mut in_class = false;
763 let mut i = 0;
764 while i < chars.len() {
765 let c = chars[i];
766 match state {
767 State::Code => {
768 let at_url = lang == Lang::Css
771 && (c == 'u' || c == 'U')
772 && chars.get(i + 1).is_some_and(|x| *x == 'r' || *x == 'R')
773 && chars.get(i + 2).is_some_and(|x| *x == 'l' || *x == 'L')
774 && chars.get(i + 3) == Some(&'(')
775 && !(i > 0
776 && (chars[i - 1].is_ascii_alphanumeric()
777 || chars[i - 1] == '_'
778 || chars[i - 1] == '-'));
779 if at_url {
780 out.extend(chars[i..i + 4].iter());
781 i += 4;
782 while i < chars.len() && chars[i].is_ascii_whitespace() {
783 out.push(chars[i]);
784 i += 1;
785 }
786 match chars.get(i) {
787 Some(&q) if q == '"' || q == '\'' => {
788 out.push(q);
789 i += 1;
790 state = State::Quoted(q);
791 }
792 _ => state = State::RawUrl,
793 }
794 continue;
795 }
796 match c {
797 '/' if chars.get(i + 1) == Some(&'*') => {
798 out.push_str(" ");
799 i += 2;
800 state = State::Block;
801 }
802 '/' if lang == Lang::Js && chars.get(i + 1) == Some(&'/') => {
803 out.push_str(" ");
804 i += 2;
805 state = State::Line;
806 }
807 '/' if lang == Lang::Js && regex_can_follow(prev) => {
808 out.push('/');
809 i += 1;
810 in_class = false;
811 state = State::Regex;
812 }
813 '\\' => {
814 out.push('\\');
817 i += 1;
818 if let Some(&next) = chars.get(i) {
819 out.push(next);
820 i += 1;
821 }
822 }
823 '"' | '\'' | '`' if lang == Lang::Js || c != '`' => {
824 out.push(c);
825 i += 1;
826 state = State::Quoted(c);
827 }
828 _ => {
829 out.push(c);
830 if !c.is_whitespace() {
831 prev = Some(c);
832 }
833 i += 1;
834 }
835 }
836 }
837 State::Regex => {
838 if c == '\\' {
839 out.push(c);
840 i += 1;
841 if let Some(&next) = chars.get(i) {
842 out.push(next);
843 i += 1;
844 }
845 } else if c == '[' && !in_class {
846 out.push(c);
847 in_class = true;
848 i += 1;
849 } else if c == '/' && !in_class {
850 out.push(c);
851 i += 1;
852 prev = Some('/');
853 state = State::Code;
854 } else if c == '\n' {
855 out.push('\n');
857 i += 1;
858 state = State::Code;
859 } else {
860 if c == ']' {
861 in_class = false;
862 }
863 out.push(c);
864 i += 1;
865 }
866 }
867 State::RawUrl => {
868 if c == '\\' {
869 out.push(c);
870 i += 1;
871 if let Some(&next) = chars.get(i) {
872 out.push(next);
873 i += 1;
874 }
875 } else {
876 out.push(c);
877 i += 1;
878 if c == ')' || c == '\n' {
879 if c == ')' {
880 prev = Some(')');
881 }
882 state = State::Code;
883 }
884 }
885 }
886 State::Block => {
887 if c == '*' && chars.get(i + 1) == Some(&'/') {
888 out.push_str(" ");
889 i += 2;
890 state = State::Code;
891 } else {
892 blank_into(&mut out, c);
893 i += 1;
894 }
895 }
896 State::Line => {
897 blank_into(&mut out, c);
898 i += 1;
899 if c == '\n' {
900 state = State::Code;
901 }
902 }
903 State::Quoted(quote) => {
904 let keep = |out: &mut String, c: char| {
905 if lang == Lang::Js {
906 out.push(c);
907 } else {
908 blank_into(out, c);
909 }
910 };
911 if c == '\\' {
912 keep(&mut out, c);
913 i += 1;
914 if let Some(&next) = chars.get(i) {
915 keep(&mut out, next);
916 i += 1;
917 }
918 } else if c == quote {
919 out.push(c);
920 i += 1;
921 prev = Some(c);
922 state = State::Code;
923 } else if lang == Lang::Css && c == '\n' {
924 blank_into(&mut out, c);
927 i += 1;
928 state = State::Code;
929 } else {
930 keep(&mut out, c);
931 i += 1;
932 }
933 }
934 }
935 }
936 out
937}
938
939fn regex_can_follow(prev: Option<char>) -> bool {
943 match prev {
944 None => true,
945 Some(c) => {
946 !(c.is_alphanumeric()
947 || c == '_'
948 || c == '$'
949 || c == ')'
950 || c == ']'
951 || c == '}'
952 || c == '"'
953 || c == '\''
954 || c == '`')
955 }
956 }
957}
958
959fn drop_layers(css: &str, names: &[&str]) -> String {
972 if names.is_empty() {
973 return css.to_owned();
974 }
975 let scan = blank(css, Lang::Css);
976 let scan = scan.as_bytes();
977 let mut out = css.as_bytes().to_vec();
978 let mut at = 0;
979 while let Some(found) = find_at_layer(scan, at) {
980 let mut i = found + b"@layer".len();
981 while i < scan.len() && scan[i].is_ascii_whitespace() {
982 i += 1;
983 }
984 let start = i;
985 while i < scan.len()
986 && (scan[i].is_ascii_alphanumeric() || matches!(scan[i], b'-' | b'_' | b'.'))
987 {
988 i += 1;
989 }
990 let name = &css[start..i];
991 while i < scan.len() && scan[i].is_ascii_whitespace() {
992 i += 1;
993 }
994 if i >= scan.len() || scan[i] != b'{' || !layer_is_beneath(name, names) {
995 at = found + 1;
996 continue;
997 }
998 let mut depth = 0usize;
999 let mut end = scan.len();
1000 let mut balanced = false;
1001 for (k, &byte) in scan.iter().enumerate().skip(i) {
1002 match byte {
1003 b'{' => depth += 1,
1004 b'}' => {
1005 depth -= 1;
1006 if depth == 0 {
1007 end = k + 1;
1008 balanced = true;
1009 break;
1010 }
1011 }
1012 _ => {}
1013 }
1014 }
1015 if balanced {
1016 blank_dropped_layer(&mut out, scan, found, i, end - 1);
1017 } else {
1018 blank_span(&mut out, found, end);
1019 }
1020 at = end;
1021 }
1022 String::from_utf8(out).unwrap_or_else(|_| css.to_owned())
1023}
1024
1025fn layer_is_beneath(name: &str, names: &[&str]) -> bool {
1029 names.iter().any(|beneath| {
1030 name == *beneath
1031 || name
1032 .strip_prefix(beneath)
1033 .is_some_and(|rest| rest.starts_with('.'))
1034 })
1035}
1036
1037fn find_at_layer(haystack: &[u8], from: usize) -> Option<usize> {
1040 let needle = b"@layer";
1041 let mut i = from;
1042 while i + needle.len() <= haystack.len() {
1043 if haystack[i..i + needle.len()].eq_ignore_ascii_case(needle) {
1044 return Some(i);
1045 }
1046 i += 1;
1047 }
1048 None
1049}
1050
1051fn blank_span(out: &mut [u8], start: usize, end: usize) {
1053 for byte in &mut out[start..end] {
1054 if *byte != b'\n' {
1055 *byte = b' ';
1056 }
1057 }
1058}
1059
1060fn blank_dropped_layer(out: &mut [u8], scan: &[u8], head: usize, open: usize, close: usize) {
1068 blank_span(out, head, open + 1);
1069 blank_dropped_content(out, scan, open + 1, close);
1070 blank_span(out, close, close + 1);
1071}
1072
1073fn blank_dropped_content(out: &mut [u8], scan: &[u8], start: usize, end: usize) {
1079 let mut i = start;
1080 while i < end {
1081 let mut open = i;
1082 while open < end && scan[open] != b'{' && scan[open] != b';' {
1083 open += 1;
1084 }
1085 if open >= end {
1086 blank_span(out, i, end);
1087 break;
1088 }
1089 if scan[open] == b';' {
1090 blank_span(out, i, open + 1);
1091 i = open + 1;
1092 continue;
1093 }
1094 let mut depth = 0usize;
1095 let mut close = end;
1096 let mut balanced = false;
1097 for (k, &byte) in scan.iter().enumerate().take(end).skip(open) {
1098 match byte {
1099 b'{' => depth += 1,
1100 b'}' => {
1101 depth -= 1;
1102 if depth == 0 {
1103 close = k;
1104 balanced = true;
1105 break;
1106 }
1107 }
1108 _ => {}
1109 }
1110 }
1111 if !balanced {
1112 blank_span(out, i, end);
1113 break;
1114 }
1115 let is_at_rule = scan[i..open]
1116 .iter()
1117 .position(|b| !b.is_ascii_whitespace())
1118 .is_some_and(|k| scan[i + k] == b'@');
1119 if is_at_rule {
1120 blank_dropped_content(out, scan, open + 1, close);
1121 } else if !has_important(&scan[open + 1..close]) {
1122 blank_span(out, i, close + 1);
1123 }
1124 i = close + 1;
1125 }
1126}
1127
1128fn has_important(bytes: &[u8]) -> bool {
1131 let keyword = b"important";
1132 let mut i = 0;
1133 while i < bytes.len() {
1134 if bytes[i] == b'!' {
1135 let mut k = i + 1;
1136 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
1137 k += 1;
1138 }
1139 if k + keyword.len() <= bytes.len()
1140 && bytes[k..k + keyword.len()].eq_ignore_ascii_case(keyword)
1141 {
1142 return true;
1143 }
1144 }
1145 i += 1;
1146 }
1147 false
1148}
1149
1150fn layers_ordered_below_makeover(css: &str) -> Vec<String> {
1158 let scan = blank(css, Lang::Css);
1159 let mut below = Vec::new();
1160 for statement in scan.split(';') {
1161 let Some(at) = statement.rfind("@layer") else {
1162 continue;
1163 };
1164 let names: Vec<&str> = statement[at + "@layer".len()..]
1165 .split(',')
1166 .map(str::trim)
1167 .collect();
1168 if names.iter().any(|name| name.contains(['{', '}'])) {
1169 continue;
1170 }
1171 if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
1172 below.extend(names[..position].iter().map(|name| (*name).to_string()));
1173 }
1174 }
1175 below
1176}
1177
1178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1185pub enum Allow<'a> {
1186 Class {
1188 class: &'a str,
1190 property: &'a str,
1192 },
1193 Element {
1196 element: &'a str,
1198 class: &'a str,
1200 property: &'a str,
1202 },
1203}
1204
1205impl<'a> Allow<'a> {
1206 pub const fn class(class: &'a str, property: &'a str) -> Self {
1208 Self::Class { class, property }
1209 }
1210
1211 pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
1213 Self::Element {
1214 element,
1215 class,
1216 property,
1217 }
1218 }
1219}
1220
1221#[derive(Debug, Clone)]
1298#[must_use = "a check does nothing until it is run"]
1299pub struct Vocabulary<'a> {
1300 source: Source,
1301 generated: Vec<&'a str>,
1302 upstream: Vec<&'a str>,
1303 allowed: Vec<Allow<'a>>,
1304 beneath: Vec<&'a str>,
1305}
1306
1307impl<'a> Vocabulary<'a> {
1308 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
1310 Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
1311 }
1312
1313 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
1319 Self::from(Source::files(paths))
1320 }
1321
1322 fn from(source: Source) -> Self {
1323 Self {
1324 source,
1325 generated: Vec::new(),
1326 upstream: Vec::new(),
1327 allowed: Vec::new(),
1328 beneath: Vec::new(),
1329 }
1330 }
1331
1332 pub fn generated(mut self, names: &[&'a str]) -> Self {
1337 self.generated.extend_from_slice(names);
1338 self
1339 }
1340
1341 pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
1351 self.upstream.extend_from_slice(sheets);
1352 self
1353 }
1354
1355 pub fn beneath(mut self, layers: &[&'a str]) -> Self {
1365 self.beneath.extend_from_slice(layers);
1366 self
1367 }
1368
1369 pub fn allow(mut self, allow: Allow<'a>) -> Self {
1371 self.allowed.push(allow);
1372 self
1373 }
1374
1375 pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
1377 self.allowed.extend(allows);
1378 self
1379 }
1380
1381 pub fn run(&self, opts: &Emit) {
1392 let classes: Vec<(&str, &str)> = self
1393 .allowed
1394 .iter()
1395 .filter_map(|allow| match *allow {
1396 Allow::Class { class, property } => Some((class, property)),
1397 Allow::Element { .. } => None,
1398 })
1399 .collect();
1400 let elements: Vec<(&str, &str, &str)> = self
1401 .allowed
1402 .iter()
1403 .filter_map(|allow| match *allow {
1404 Allow::Element {
1405 element,
1406 class,
1407 property,
1408 } => Some((element, class, property)),
1409 Allow::Class { .. } => None,
1410 })
1411 .collect();
1412 match &self.source {
1413 Source::Frontend(frontend) => {
1414 let css = frontend.join("css");
1415 let files: Vec<PathBuf> = files_with_extension(&css, "css")
1416 .into_iter()
1417 .filter(|p| {
1418 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
1419 !self.generated.contains(&name.as_str())
1420 })
1421 .collect();
1422 check_vocabulary_paths(
1423 &files,
1424 opts,
1425 Some(frontend),
1426 &self.upstream,
1427 &self.beneath,
1428 &classes,
1429 &elements,
1430 );
1431 println!("cargo::rerun-if-changed={}", css.display());
1433 }
1434 Source::Files(paths) => {
1435 let files: Vec<PathBuf> = paths
1436 .iter()
1437 .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
1438 .cloned()
1439 .collect();
1440 check_vocabulary_paths(
1441 &files,
1442 opts,
1443 None,
1444 &self.upstream,
1445 &self.beneath,
1446 &classes,
1447 &elements,
1448 );
1449 }
1450 }
1451 }
1452}
1453
1454fn check_vocabulary_paths(
1456 paths: &[PathBuf],
1457 opts: &Emit,
1458 root: Option<&Path>,
1459 upstream: &[&str],
1460 beneath: &[&str],
1461 allowed: &[(&str, &str)],
1462 allowed_elements: &[(&str, &str, &str)],
1463) {
1464 let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
1465 let mut owned = makeover.clone();
1468 for sheet in upstream {
1469 for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
1470 owned.entry(class).or_default().extend(properties);
1471 }
1472 }
1473 let makeover_variants = crate::vocabulary::variants_by_class(&crate::stylesheet(opts));
1476 let mut owned_variants = makeover_variants.clone();
1477 for sheet in upstream {
1478 for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
1479 let entry = owned_variants.entry(class).or_default();
1480 for (arm, properties) in arms {
1481 entry.entry(arm).or_default().extend(properties);
1482 }
1483 }
1484 }
1485 let mut cancels: Vec<String> = Vec::new();
1486 let mut clashes: Vec<String> = Vec::new();
1487 let mut seen: Vec<(String, String)> = Vec::new();
1488 let mut element_clashes: Vec<String> = Vec::new();
1489 let mut element_seen: Vec<(String, String, String)> = Vec::new();
1490
1491 for path in paths {
1492 println!("cargo::rerun-if-changed={}", path.display());
1493 let raw = std::fs::read_to_string(path)
1494 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1495 let name = match root {
1496 Some(root) => display_name(root, path),
1497 None => path.display().to_string(),
1498 };
1499 let (generated, variants) = if upstream.contains(&raw.as_str()) {
1502 (&makeover, &makeover_variants)
1503 } else {
1504 (&owned, &owned_variants)
1505 };
1506 let raw = drop_layers(&raw, beneath);
1509 let local = crate::vocabulary::declarations_by_class(&raw);
1512 for (class, properties) in &local {
1513 let Some(theirs) = generated.get(class) else {
1514 continue;
1515 };
1516 for property in properties.intersection(theirs) {
1517 seen.push((class.clone(), property.clone()));
1518 if allowed.contains(&(class.as_str(), property.as_str())) {
1519 continue;
1520 }
1521 clashes.push(format!(" {name} .{class} {{ {property} }}"));
1522 }
1523 }
1524
1525 let own_below = layers_ordered_below_makeover(&raw);
1536 let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
1537 let above = drop_layers(&raw, &own_below);
1538 let bare = crate::vocabulary::bare_declarations(&above);
1539 let restated = crate::vocabulary::mentions_by_arm(&raw);
1540 for (class, properties) in &bare {
1541 let Some(arms) = variants.get(class) else {
1542 continue;
1543 };
1544 for property in properties {
1545 let lost: Vec<&str> = arms
1546 .iter()
1547 .filter(|(arm, theirs)| {
1548 theirs.contains(property)
1549 && !restated
1550 .get(arm.as_str())
1551 .is_some_and(|mentioned| mentioned.contains(property))
1552 })
1553 .map(|(arm, _)| arm.as_str())
1554 .collect();
1555 if lost.is_empty() {
1556 continue;
1557 }
1558 cancels.push(format!(
1559 " {name} .{class} {{ {property} }} cancels {}",
1560 lost.iter()
1561 .map(|arm| format!("{arm} {{ {property} }}"))
1562 .collect::<Vec<_>>()
1563 .join(", ")
1564 ));
1565 }
1566 }
1567
1568 let mentioned = crate::vocabulary::mentions_by_class(&raw);
1574 let by_element = crate::vocabulary::declarations_by_element(&raw);
1575 for (element, properties) in &by_element {
1576 for class in crate::vocabulary::classes_for_element(element, opts) {
1577 let Some(theirs) = generated.get(&class) else {
1578 continue;
1579 };
1580 for (property, rank) in properties {
1581 if !theirs.contains(property) {
1582 continue;
1583 }
1584 let spoken_for = mentioned
1588 .get(&class)
1589 .and_then(|properties| properties.get(property))
1590 .is_some_and(|theirs| theirs >= rank);
1591 if spoken_for {
1592 continue;
1593 }
1594 element_seen.push((element.clone(), class.clone(), property.clone()));
1595 if allowed_elements.contains(&(
1596 element.as_str(),
1597 class.as_str(),
1598 property.as_str(),
1599 )) {
1600 continue;
1601 }
1602 element_clashes.push(format!(
1603 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
1604 ));
1605 }
1606 }
1607 }
1608 }
1609
1610 assert!(
1611 cancels.is_empty(),
1612 "{} hand-written rule(s) on a bare generated class set a property the \
1613 design system varies on that class by state, tone or context. The app \
1614 sheet sits in a later cascade layer than @layer makeover, and layers \
1615 resolve before specificity, so each of these beats every variant named \
1616 beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
1617 cancelled variant in the app's sheet on its own arm, after the bare rule \
1618 (`color: revert-layer` hands the design system's value back). That is \
1619 the remedy the cascade allows; an allowed-list entry does not cover \
1620 this, because the reviewed pair is the class and the variants are \
1621 what it silently takes.",
1622 cancels.len(),
1623 cancels.join("\n")
1624 );
1625
1626 assert!(
1627 clashes.is_empty(),
1628 "{} hand-written declaration(s) take a property the generated stylesheet, \
1629 or an upstream sheet named to this check, already sets on the same \
1630 class. App CSS wins over @layer makeover, whether by a later layer or \
1631 by being unlayered, so each of these wins over the design system \
1632 silently:\n{}\n\nDelete the declaration, or, if \
1633 it is a deliberate pairing on a different selector arm, add \
1634 (class, property) to this check's allowed list and say why beside it. \
1635 Count the consumers before deciding a divergence is worth keeping.",
1636 clashes.len(),
1637 clashes.join("\n")
1638 );
1639
1640 assert!(
1641 element_clashes.is_empty(),
1642 "{} hand-written element rule(s) take a property the generated \
1643 stylesheet sets on a class that element carries. App CSS wins over \
1644 @layer makeover, whether by a later layer or by being unlayered, so a \
1645 described component rendered on one of these elements loses the \
1646 design system's version of that property silently, which can draw a \
1647 destructive act like an ordinary one:\n{}\n\nHand the \
1648 property back on the arms makeover paints \
1649 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1650 rule so it stops reaching described markup, or add \
1651 (element, class, property) to this check's allowed-elements list and \
1652 say why beside it.",
1653 element_clashes.len(),
1654 element_clashes.join("\n")
1655 );
1656
1657 let stale: Vec<&(&str, &str)> = allowed
1658 .iter()
1659 .filter(|(class, property)| {
1660 !seen.contains(&((*class).to_string(), (*property).to_string()))
1661 })
1662 .collect();
1663 assert!(
1664 stale.is_empty(),
1665 "the allowed list declares {stale:?}, which no longer collides with \
1666 anything. Delete the entries: an exception nobody is using is where the \
1667 next real collision lands and reads as company."
1668 );
1669
1670 let stale: Vec<&(&str, &str, &str)> = allowed_elements
1671 .iter()
1672 .filter(|(element, class, property)| {
1673 !element_seen.contains(&(
1674 (*element).to_string(),
1675 (*class).to_string(),
1676 (*property).to_string(),
1677 ))
1678 })
1679 .collect();
1680 assert!(
1681 stale.is_empty(),
1682 "the allowed-elements list declares {stale:?}, which no longer collides \
1683 with anything. Delete the entries: an exception nobody is using is \
1684 where the next real collision lands and reads as company."
1685 );
1686}
1687
1688fn haystack_names(haystack: &str, class: &str) -> bool {
1692 let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
1693 haystack.match_indices(class).any(|(at, found)| {
1694 haystack[..at].chars().next_back().is_none_or(|c| !part(c))
1695 && haystack[at + found.len()..]
1696 .chars()
1697 .next()
1698 .is_none_or(|c| !part(c))
1699 })
1700}
1701
1702pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1726 let generated = crate::vocabulary::names(opts);
1727 let mut haystack = String::new();
1728 for path in markup {
1729 let path = path.as_ref();
1730 println!("cargo::rerun-if-changed={}", path.display());
1731 haystack.push_str(
1732 &std::fs::read_to_string(path)
1733 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
1734 );
1735 haystack.push('\n');
1736 }
1737
1738 const LADDERS: &[&str] = &["min-", "short-"];
1744 let rung_of = |class: &str| -> Option<&'static str> {
1745 let name = class.strip_prefix(opts.class_prefix)?;
1746 LADDERS.iter().copied().find(|ladder| {
1747 name.strip_prefix(ladder)
1748 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1749 })
1750 };
1751 let mut unused: Vec<String> = generated
1752 .iter()
1753 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1754 .cloned()
1755 .collect();
1756 for ladder in LADDERS {
1757 let rungs: Vec<&String> = generated
1758 .iter()
1759 .filter(|class| rung_of(class) == Some(ladder))
1760 .collect();
1761 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1762 unused.push(format!("{}{ladder}N", opts.class_prefix));
1763 }
1764 }
1765
1766 assert!(
1767 unused.len() <= high_water,
1768 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1769 The vocabulary grew or the markup stopped using it:\n{}",
1770 unused.len(),
1771 generated.len(),
1772 high_water,
1773 unused
1774 .iter()
1775 .map(|c| format!(" .{c}"))
1776 .collect::<Vec<_>>()
1777 .join("\n")
1778 );
1779
1780 if unused.len() < high_water {
1781 println!(
1782 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1783 lower the seal so it cannot grow back",
1784 unused.len(),
1785 high_water
1786 );
1787 }
1788}
1789
1790#[cfg(test)]
1791mod tests {
1792 use super::*;
1793 use std::fmt::Write as _;
1794
1795 fn scratch(name: &str) -> PathBuf {
1796 let dir =
1797 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
1798 let _ = std::fs::remove_dir_all(&dir);
1799 std::fs::create_dir_all(&dir).expect("create scratch");
1800 dir
1801 }
1802
1803 fn write(dir: &Path, name: &str, src: &str) {
1804 if let Some(parent) = dir.join(name).parent() {
1805 std::fs::create_dir_all(parent).unwrap();
1806 }
1807 std::fs::write(dir.join(name), src).unwrap();
1808 }
1809
1810 fn declaring() -> String {
1811 format!(
1812 "const {CONST_NAME} = '{}';\n",
1813 Density::Touch.media_condition()
1814 )
1815 }
1816
1817 #[test]
1818 fn the_crates_own_string_passes() {
1819 let dir = scratch("ok");
1820 write(&dir, "touch.js", &declaring());
1821 check_touch_density(&dir);
1822 }
1823
1824 #[test]
1825 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1826 fn a_drifted_literal_fails() {
1827 let dir = scratch("drift");
1828 write(&dir, "touch.js", &declaring());
1829 write(
1830 &dir,
1831 "haptics.js",
1832 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1833 );
1834 check_touch_density(&dir);
1835 }
1836
1837 #[test]
1838 #[should_panic(expected = "device sniff")]
1839 fn the_sniff_cannot_come_back() {
1840 let dir = scratch("sniff");
1841 write(&dir, "touch.js", &declaring());
1842 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1843 check_touch_density(&dir);
1844 }
1845
1846 #[test]
1847 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1848 fn a_frontend_that_states_nothing_fails() {
1849 let dir = scratch("empty");
1850 write(&dir, "app.js", "export const x = 1;\n");
1851 check_touch_density(&dir);
1852 }
1853
1854 #[test]
1855 fn a_use_site_is_not_a_declaration() {
1856 let src =
1860 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1861 assert!(touch_density_literals(&src).is_empty());
1862 }
1863
1864 #[test]
1865 fn whitespace_and_backtick_literals_read() {
1866 let want = Density::Touch.media_condition();
1867 for src in [
1868 format!("const {CONST_NAME}='{want}';\n"),
1869 format!("const {CONST_NAME} = `{want}`;\n"),
1870 format!("const {CONST_NAME} =\n '{want}';\n"),
1871 ] {
1872 let found = touch_density_literals(&src);
1873 assert_eq!(found.len(), 1, "src: {src:?}");
1874 assert_eq!(found[0].1, want, "src: {src:?}");
1875 }
1876 }
1877
1878 #[test]
1879 fn a_longer_identifier_is_not_the_const() {
1880 let want = Density::Touch.media_condition();
1882 for src in [
1883 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1884 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1885 ] {
1886 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1887 }
1888 }
1889
1890 #[test]
1891 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1892 fn a_commented_out_declaration_is_not_a_declaration() {
1893 let dir = scratch("commented-decl");
1896 write(
1897 &dir,
1898 "touch.js",
1899 &format!("// {}\n", declaring().trim_end()),
1900 );
1901 check_touch_density(&dir);
1902 }
1903
1904 #[test]
1905 fn a_sniff_named_in_a_comment_is_not_a_use() {
1906 let dir = scratch("sniff-comment");
1908 write(&dir, "touch.js", &declaring());
1909 write(
1910 &dir,
1911 "note.js",
1912 "// never read ontouchstart or maxTouchPoints here\n",
1913 );
1914 check_touch_density(&dir);
1915 }
1916
1917 #[test]
1918 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1919 fn a_wrong_literal_without_spaces_is_caught() {
1920 let dir = scratch("loose-drift");
1922 write(&dir, "touch.js", &declaring());
1923 write(
1924 &dir,
1925 "haptics.js",
1926 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1927 );
1928 check_touch_density(&dir);
1929 }
1930
1931 #[test]
1932 fn nested_files_are_read() {
1933 let dir = scratch("nested");
1936 write(&dir, "touch.js", &declaring());
1937 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1938 let files = js_files(&dir);
1939 assert_eq!(files.len(), 2);
1940 }
1941
1942 #[test]
1943 fn a_non_js_file_is_ignored() {
1944 let dir = scratch("nonjs");
1945 write(&dir, "touch.js", &declaring());
1946 write(&dir, "styles.css", "body { }\n");
1947 assert_eq!(js_files(&dir).len(), 1);
1948 }
1949
1950 fn frontend(name: &str) -> PathBuf {
1951 let dir = scratch(name);
1952 std::fs::create_dir_all(dir.join("css")).unwrap();
1953 std::fs::create_dir_all(dir.join("js")).unwrap();
1954 dir
1955 }
1956
1957 fn boundary() -> u16 {
1959 SizeClass::Medium.min_px()
1960 }
1961
1962 #[test]
1963 fn the_crates_own_boundaries_pass() {
1964 let dir = frontend("bp-ok");
1965 write(
1966 &dir,
1967 "css/styles.css",
1968 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1969 );
1970 Breakpoints::in_frontend(&dir).run();
1971 }
1972
1973 #[test]
1974 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1975 fn a_stale_css_width_fails() {
1976 let dir = frontend("bp-css");
1977 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1978 Breakpoints::in_frontend(&dir).run();
1979 }
1980
1981 #[test]
1982 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1983 fn a_stale_js_width_fails() {
1984 let dir = frontend("bp-js");
1985 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1986 Breakpoints::in_frontend(&dir).run();
1987 }
1988
1989 #[test]
1990 fn a_declared_tuning_width_passes() {
1991 let dir = frontend("bp-tuning");
1992 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1993 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1994 }
1995
1996 #[test]
1997 fn a_width_in_a_comment_is_prose() {
1998 let dir = frontend("bp-comment");
2001 write(
2002 &dir,
2003 "css/styles.css",
2004 "/* was @media (max-width: 768px) until the size classes landed */\n",
2005 );
2006 Breakpoints::in_frontend(&dir).run();
2007 }
2008
2009 #[test]
2010 fn an_unparenthesized_width_is_not_a_breakpoint() {
2011 let dir = frontend("bp-inline");
2015 write(
2016 &dir,
2017 "js/style.js",
2018 "el.style.cssText = 'max-width: 320px; display: block';\n",
2019 );
2020 Breakpoints::in_frontend(&dir).run();
2021 }
2022
2023 #[test]
2024 fn nested_css_is_read() {
2025 let dir = frontend("bp-nested");
2028 write(
2029 &dir,
2030 "css/screens/detail.css",
2031 "@media (max-width: 768px) { }\n",
2032 );
2033 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
2034 assert!(found.is_err(), "a nested stylesheet must be scanned");
2035 }
2036
2037 #[test]
2038 fn a_css_only_frontend_scans_without_a_js_directory() {
2039 let dir = scratch("bp-css-only");
2042 write(
2043 &dir,
2044 "css/styles.css",
2045 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
2046 );
2047 Breakpoints::in_frontend(&dir).run();
2048 }
2049
2050 #[test]
2051 fn a_named_list_is_checked() {
2052 let dir = frontend("bp-list");
2053 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
2054 let listed = dir.join("css/style.css");
2055 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
2056 let msg = err.downcast_ref::<String>().expect("String payload");
2057 assert!(msg.contains("style.css:1"), "got: {msg}");
2058 }
2059
2060 #[test]
2061 #[should_panic(expected = "read ")]
2062 fn a_listed_file_that_is_gone_fails() {
2063 let dir = frontend("bp-missing");
2066 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
2067 }
2068
2069 #[test]
2070 fn a_listed_js_file_is_parsed_as_script() {
2071 let dir = frontend("bp-list-js");
2074 write(
2075 &dir,
2076 "js/style.js",
2077 "el.style.cssText = 'max-width: 320px';\n",
2078 );
2079 Breakpoints::files(&[dir.join("js/style.js")]).run();
2080 }
2081
2082 #[test]
2083 fn the_error_names_the_file_and_line() {
2084 let dir = frontend("bp-message");
2085 write(
2086 &dir,
2087 "css/styles.css",
2088 "body { }\n@media (max-width: 768px) { }\n",
2089 );
2090 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
2091 let msg = err
2092 .downcast_ref::<String>()
2093 .expect("panic payload is a String");
2094 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
2095 }
2096
2097 #[test]
2098 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
2099 let upstream = ".tile-set { gap: 4px; }\n";
2102 let dir = scratch("vocab-upstream");
2103 write(&dir, "css/quasi.css", upstream);
2104 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
2105 let err = std::panic::catch_unwind(|| {
2106 Vocabulary::in_frontend(&dir)
2107 .upstream(&[upstream])
2108 .run(&Emit::default());
2109 })
2110 .unwrap_err();
2111 let msg = err
2112 .downcast_ref::<String>()
2113 .expect("panic payload is a String");
2114 assert!(
2115 msg.contains("css/styles.css .tile-set { gap }"),
2116 "got: {msg}"
2117 );
2118 assert!(
2119 !msg.contains("css/quasi.css"),
2120 "the served copy clashed with itself: {msg}"
2121 );
2122
2123 Vocabulary::in_frontend(&dir).run(&Emit::default());
2126 }
2127
2128 #[test]
2129 fn a_rule_restating_a_generated_class_fails_and_names_it() {
2130 let dir = scratch("vocab-clash");
2131 write(
2134 &dir,
2135 "css/styles.css",
2136 "body { color: red; }\n.card { box-shadow: none; }\n",
2137 );
2138 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2139 .unwrap_err();
2140 let msg = err
2141 .downcast_ref::<String>()
2142 .expect("panic payload is a String");
2143 assert!(msg.contains(".card"), "got: {msg}");
2144 assert!(msg.contains("box-shadow"), "got: {msg}");
2145 assert!(msg.contains("css/styles.css"), "got: {msg}");
2146 }
2147
2148 #[test]
2149 fn an_app_class_of_its_own_is_left_alone() {
2150 let dir = scratch("vocab-clean");
2151 write(
2152 &dir,
2153 "css/styles.css",
2154 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
2155 );
2156 Vocabulary::in_frontend(&dir).run(&Emit::default());
2157 }
2158
2159 #[test]
2160 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
2161 let dir = scratch("vocab-generated");
2162 let opts = Emit::default();
2163 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
2164 Vocabulary::in_frontend(&dir)
2167 .generated(&["layout.css"])
2168 .run(&opts);
2169 }
2170
2171 #[test]
2172 fn a_prefixed_app_is_checked_against_its_own_prefix() {
2173 let dir = scratch("vocab-prefix");
2174 let opts = Emit::new().class_prefix("mo-");
2175 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
2178 Vocabulary::in_frontend(&dir).run(&opts);
2179
2180 let dir = scratch("vocab-prefix-clash");
2181 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
2182 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
2183 }
2184
2185 #[test]
2186 fn a_class_shared_without_a_shared_property_is_left_alone() {
2187 let dir = scratch("vocab-additive");
2188 write(
2191 &dir,
2192 "css/styles.css",
2193 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
2194 );
2195 Vocabulary::in_frontend(&dir).run(&Emit::default());
2196 }
2197
2198 #[test]
2199 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
2200 let dir = scratch("vocab-allowed");
2201 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
2202 Vocabulary::in_frontend(&dir)
2203 .allow(Allow::class("badge", "padding"))
2204 .run(&Emit::default());
2205
2206 let dir = scratch("vocab-allowed-stale");
2209 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
2210 let err = std::panic::catch_unwind(|| {
2211 Vocabulary::in_frontend(&dir)
2212 .allow(Allow::class("badge", "padding"))
2213 .run(&Emit::default());
2214 })
2215 .unwrap_err();
2216 let msg = err
2217 .downcast_ref::<String>()
2218 .expect("panic payload is a String");
2219 assert!(msg.contains("no longer collides"), "got: {msg}");
2220 }
2221
2222 #[test]
2223 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2224 let dir = scratch("vocab-element");
2225 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2229 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2230 .unwrap_err();
2231 let msg = err
2232 .downcast_ref::<String>()
2233 .expect("panic payload is a String");
2234 assert!(msg.contains("select {"), "got: {msg}");
2235 assert!(msg.contains(".field"), "got: {msg}");
2236 assert!(msg.contains("box-shadow"), "got: {msg}");
2237 assert!(msg.contains("css/styles.css"), "got: {msg}");
2238 }
2239
2240 #[test]
2241 fn a_universal_reset_after_the_design_system_is_caught() {
2242 let dir = scratch("vocab-universal");
2246 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2247 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2248 .unwrap_err();
2249 let msg = err
2250 .downcast_ref::<String>()
2251 .expect("panic payload is a String");
2252 assert!(msg.contains("* {"), "got: {msg}");
2253 assert!(msg.contains("padding"), "got: {msg}");
2254 }
2255
2256 #[test]
2257 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2258 let dir = scratch("vocab-universal-beneath");
2259 write(
2260 &dir,
2261 "css/styles.css",
2262 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2263 );
2264 Vocabulary::in_frontend(&dir)
2265 .beneath(&["reset"])
2266 .run(&Emit::default());
2267 }
2268
2269 #[test]
2270 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2271 let dir = scratch("vocab-important-beneath");
2272 write(
2277 &dir,
2278 "css/styles.css",
2279 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2280 );
2281 let err = std::panic::catch_unwind(|| {
2282 Vocabulary::in_frontend(&dir)
2283 .beneath(&["reset"])
2284 .run(&Emit::default());
2285 })
2286 .unwrap_err();
2287 let msg = err.downcast_ref::<String>().expect("a String payload");
2288 assert!(msg.contains("box-shadow"), "got: {msg}");
2289 }
2290
2291 #[test]
2292 fn a_layer_not_named_beneath_is_still_read() {
2293 let dir = scratch("vocab-universal-other-layer");
2294 write(
2295 &dir,
2296 "css/styles.css",
2297 "@layer base {\n * { padding: 0; }\n}\n",
2298 );
2299 let err = std::panic::catch_unwind(|| {
2300 Vocabulary::in_frontend(&dir)
2301 .beneath(&["reset"])
2302 .run(&Emit::default());
2303 })
2304 .unwrap_err();
2305 let msg = err.downcast_ref::<String>().expect("a String payload");
2306 assert!(msg.contains("* {"), "got: {msg}");
2307 }
2308
2309 #[test]
2310 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2311 let dir = scratch("vocab-universal-box");
2312 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2313 Vocabulary::in_frontend(&dir).run(&Emit::default());
2314 }
2315
2316 #[test]
2317 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2318 let dir = scratch("vocab-element-handoff");
2319 write(
2324 &dir,
2325 "css/styles.css",
2326 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2327 );
2328 Vocabulary::in_frontend(&dir).run(&Emit::default());
2329 }
2330
2331 #[test]
2332 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2333 let dir = scratch("vocab-element-spoken-for");
2334 write(
2339 &dir,
2340 "css/styles.css",
2341 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2342 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2343 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2344 );
2345 Vocabulary::in_frontend(&dir)
2346 .allow(Allow::class("field", "box-shadow"))
2347 .run(&Emit::default());
2348 }
2349
2350 #[test]
2351 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2352 let dir = scratch("vocab-element-weak-handoff");
2353 write(
2357 &dir,
2358 "css/styles.css",
2359 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2360 );
2361 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2362 .unwrap_err();
2363 let msg = err
2364 .downcast_ref::<String>()
2365 .expect("panic payload is a String");
2366 assert!(msg.contains(".field"), "got: {msg}");
2367
2368 let dir = scratch("vocab-element-strong-handoff");
2370 write(
2371 &dir,
2372 "css/styles.css",
2373 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2374 );
2375 Vocabulary::in_frontend(&dir).run(&Emit::default());
2376 }
2377
2378 #[test]
2379 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2380 let dir = scratch("vocab-element-scoped");
2381 write(
2385 &dir,
2386 "css/styles.css",
2387 ".wizard select { box-shadow: none; }\n",
2388 );
2389 Vocabulary::in_frontend(&dir).run(&Emit::default());
2390 }
2391
2392 #[test]
2393 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2394 let dir = scratch("vocab-element-unpaired");
2395 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2398 Vocabulary::in_frontend(&dir).run(&Emit::default());
2399 }
2400
2401 #[test]
2402 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2403 let dir = scratch("vocab-cancel-tone");
2408 write(
2409 &dir,
2410 "css/styles.css",
2411 ".button { background: var(--surface-raised); }\n",
2412 );
2413 let err = std::panic::catch_unwind(|| {
2414 Vocabulary::in_frontend(&dir)
2415 .allow(Allow::class("button", "background"))
2416 .run(&Emit::default());
2417 })
2418 .unwrap_err();
2419 let msg = err
2420 .downcast_ref::<String>()
2421 .expect("panic payload is a String");
2422 assert!(
2423 msg.contains(".button { background } cancels"),
2424 "got: {msg}"
2425 );
2426 assert!(
2427 msg.contains(".button[data-tone=\"danger\"] { background }"),
2428 "the message names the variant it cancels: {msg}"
2429 );
2430 assert!(msg.contains("restate"), "and the remedy: {msg}");
2431 }
2432
2433 #[test]
2434 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2435 let opts = Emit::default();
2438 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2439 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2440 for (arm, properties) in &variants["button"] {
2441 if properties.contains("background") {
2442 let loose = arm.replace('"', "'").replace(',', " , ");
2443 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2444 }
2445 }
2446 let dir = scratch("vocab-cancel-restated");
2447 write(&dir, "css/styles.css", &css);
2448 Vocabulary::in_frontend(&dir)
2449 .allow(Allow::class("button", "background"))
2450 .run(&opts);
2451 }
2452
2453 #[test]
2454 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2455 let dir = scratch("vocab-cancel-scoped");
2458 write(
2459 &dir,
2460 "css/styles.css",
2461 ".toolbar .button { color: var(--content); }\n\
2462 .button.quiet { color: var(--content); }\n",
2463 );
2464 Vocabulary::in_frontend(&dir)
2467 .allow(Allow::class("button", "color"))
2468 .run(&Emit::default());
2469 }
2470
2471 #[test]
2472 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2473 let dir = scratch("vocab-cancel-own-order");
2477 write(
2478 &dir,
2479 "css/quasi.css",
2480 "@layer reset, quasi, makeover;\n\
2481 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2482 );
2483 Vocabulary::in_frontend(&dir)
2484 .allow(Allow::class("button", "cursor"))
2485 .run(&Emit::default());
2486
2487 let dir = scratch("vocab-cancel-own-order-above");
2489 write(
2490 &dir,
2491 "css/app.css",
2492 "@layer reset, makeover, app;\n\
2493 @layer app {\n .button { cursor: pointer; }\n}\n",
2494 );
2495 let err = std::panic::catch_unwind(|| {
2496 Vocabulary::in_frontend(&dir)
2497 .allow(Allow::class("button", "cursor"))
2498 .run(&Emit::default());
2499 })
2500 .unwrap_err();
2501 let msg = err
2502 .downcast_ref::<String>()
2503 .expect("panic payload is a String");
2504 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2505 }
2506
2507 #[test]
2508 fn a_handoff_on_the_bare_class_cancels_nothing() {
2509 let dir = scratch("vocab-cancel-handoff");
2510 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2511 Vocabulary::in_frontend(&dir).run(&Emit::default());
2512 }
2513
2514 #[test]
2515 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2516 let dir = scratch("vocab-element-allowed");
2517 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2518 Vocabulary::in_frontend(&dir)
2519 .allow(Allow::element("select", "field", "box-shadow"))
2520 .run(&Emit::default());
2521
2522 let dir = scratch("vocab-element-allowed-stale");
2525 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2526 let err = std::panic::catch_unwind(|| {
2527 Vocabulary::in_frontend(&dir)
2528 .allow(Allow::element("select", "field", "box-shadow"))
2529 .run(&Emit::default());
2530 })
2531 .unwrap_err();
2532 let msg = err
2533 .downcast_ref::<String>()
2534 .expect("panic payload is a String");
2535 assert!(msg.contains("no longer collides"), "got: {msg}");
2536 }
2537
2538 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2540 let mut rungs = 0;
2541 let mut ladders = 0;
2542 for ladder in ["min-", "short-"] {
2543 let here = names
2544 .iter()
2545 .filter(|c| {
2546 c.strip_prefix(ladder)
2547 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2548 })
2549 .count();
2550 rungs += here;
2551 ladders += usize::from(here > 0);
2552 }
2553 (rungs, ladders)
2554 }
2555
2556 #[test]
2557 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2558 let dir = scratch("vocab-seal");
2559 let opts = Emit::default();
2560 let names = crate::vocabulary::names(&opts);
2561 let (rungs, ladders) = ladders(&names);
2562 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2563 let dead = names.len() - rungs + ladders;
2566 write(&dir, "index.html", "<div></div>\n");
2567 let markup = [dir.join("index.html")];
2568
2569 check_vocabulary_use(&markup, &opts, dead);
2570 assert!(
2571 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2572 "a vocabulary deader than the seal has to fail"
2573 );
2574 }
2575
2576 #[test]
2577 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2578 let opts = Emit::default();
2581 let names = crate::vocabulary::names(&opts);
2582 let (rungs, ladders) = ladders(&names);
2583 let dead = names.len() - rungs + ladders;
2584
2585 let dir = scratch("vocab-ladder");
2586 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2587 let one_rung = [dir.join("index.html")];
2588 check_vocabulary_use(&one_rung, &opts, dead - 2);
2590
2591 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2593 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2594 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2595 }
2596
2597 #[test]
2598 fn a_class_is_named_whole_on_both_sides() {
2599 assert!(haystack_names("class=\"row\"", "row"));
2600 assert!(!haystack_names("class=\"row-flex\"", "row"));
2601 assert!(!haystack_names("class=\"flex-row\"", "row"));
2602 assert!(haystack_names("el.classList.add('row')", "row"));
2603 }
2604
2605 #[test]
2606 fn range_syntax_and_units_are_read() {
2607 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2608 assert_eq!(
2609 media_widths(" (600px <= width < 840px)"),
2610 [Width::Px(600), Width::Px(840)]
2611 );
2612 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2613 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2614 assert_eq!(
2615 media_widths(" (max-width: 599.98px)"),
2616 [Width::Other("599.98px".into())]
2617 );
2618 assert_eq!(
2619 media_widths(" (width < 50vw)"),
2620 [Width::Other("50vw".into())]
2621 );
2622 assert!(media_widths(" (min-height: 600px)").is_empty());
2623 assert!(media_widths(" (max-device-width: 600px)").is_empty());
2624 assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
2625 }
2626
2627 #[test]
2628 fn every_size_class_condition_passes_its_own_check() {
2629 let dir = frontend("bp-classes");
2630 let mut css = String::new();
2631 for class in SizeClass::all() {
2632 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2633 }
2634 write(&dir, "css/styles.css", &css);
2635 write(
2636 &dir,
2637 "js/shell.js",
2638 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2639 );
2640 Breakpoints::in_frontend(&dir).run();
2641 }
2642
2643 #[test]
2644 #[should_panic(expected = "48em")]
2645 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2646 let dir = frontend("bp-em");
2647 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2648 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2649 }
2650
2651 #[test]
2652 #[should_panic(expected = "(width < 768px)")]
2653 fn a_stale_range_width_fails() {
2654 let dir = frontend("bp-range");
2655 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2656 Breakpoints::in_frontend(&dir).run();
2657 }
2658
2659 #[test]
2660 #[should_panic(expected = "599px")]
2661 fn the_old_inclusive_compact_bound_is_stale() {
2662 let dir = frontend("bp-599");
2663 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2664 Breakpoints::in_frontend(&dir).run();
2665 }
2666
2667 #[test]
2668 #[should_panic(expected = "(max-width: 600px)")]
2669 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2670 let dir = frontend("bp-600-inclusive");
2674 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2675 Breakpoints::in_frontend(&dir).run();
2676 }
2677
2678 #[test]
2679 #[should_panic(expected = "(width <= 600px)")]
2680 fn an_inclusive_range_upper_bound_is_stale() {
2681 let dir = frontend("bp-le-600");
2682 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2683 Breakpoints::in_frontend(&dir).run();
2684 }
2685
2686 #[test]
2687 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2688 let dir = frontend("bp-edges-ok");
2689 write(
2690 &dir,
2691 "css/styles.css",
2692 "@media (min-width: 600px) { }\n\
2693 @media (600px <= width) { }\n\
2694 @media (width >= 840px) { }\n",
2695 );
2696 Breakpoints::in_frontend(&dir).run();
2697 }
2698
2699 #[test]
2700 fn a_tuning_max_width_is_left_alone() {
2701 let dir = frontend("bp-tuning-max");
2704 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2705 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2706 }
2707
2708 #[test]
2709 fn a_call_in_script_is_not_a_feature() {
2710 assert!(js_widths("const w = Math.max(width, 600);").is_empty());
2711 assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
2712 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2713 }
2714
2715 #[test]
2716 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2717 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2718 let stripped = blank(css, Lang::Css);
2719 assert!(!stripped.contains("768"), "{stripped:?}");
2720 assert!(stripped.contains("a { }"));
2721 assert_eq!(stripped.len(), css.len());
2722 }
2723
2724 #[test]
2725 fn a_comment_opener_inside_a_string_is_text() {
2726 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2728 let src = blank(css, Lang::Css);
2729 assert_eq!(media_conditions(&src).len(), 1);
2730 assert!(src.contains("768"));
2731
2732 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2733 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2734 }
2735
2736 #[test]
2737 fn a_regex_literal_does_not_open_a_comment() {
2738 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2741 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2742 }
2743
2744 #[test]
2745 fn a_division_is_not_a_regex_literal() {
2746 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2748 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2749 }
2750
2751 #[test]
2752 fn a_css_bad_string_ends_at_a_newline() {
2753 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2756 let src = blank(css, Lang::Css);
2757 assert_eq!(media_conditions(&src).len(), 1);
2758 assert!(src.contains("768"));
2759 }
2760
2761 #[test]
2762 fn an_escaped_quote_does_not_open_a_string() {
2763 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2764 let src = blank(css, Lang::Css);
2765 assert_eq!(media_conditions(&src).len(), 1);
2766 }
2767
2768 #[test]
2769 fn an_unquoted_url_does_not_open_a_comment() {
2770 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2771 let src = blank(css, Lang::Css);
2772 assert_eq!(media_conditions(&src).len(), 1);
2773 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2775 @media (max-width: 768px) { }\n";
2776 let src = blank(css, Lang::Css);
2777 assert!(!src.contains("gone"), "{src:?}");
2778 assert_eq!(media_conditions(&src).len(), 1);
2779 }
2780
2781 #[test]
2782 fn a_leading_dot_length_is_read() {
2783 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2785 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2786 }
2787
2788 #[test]
2789 fn an_at_media_keyword_reads_in_any_case() {
2790 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2791 assert_eq!(conditions.len(), 1);
2792 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2793 }
2794
2795 #[test]
2796 fn a_width_nested_in_a_function_is_read() {
2797 assert_eq!(
2798 media_widths("(max-width: calc(768px))"),
2799 vec![Width::Px(768)]
2800 );
2801 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2804 }
2805
2806 #[test]
2807 fn a_newline_does_not_reset_an_open_group() {
2808 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2809 }
2810
2811 #[test]
2812 fn custom_media_and_import_carry_media_queries() {
2813 let css = "@custom-media --narrow (max-width: 768px);\n\
2814 @import url(x.css) (max-width: 768px);\n";
2815 let conditions = media_conditions(css);
2816 assert_eq!(conditions.len(), 2);
2817 for (_, condition) in conditions {
2818 assert!(
2819 media_widths(condition).contains(&Width::Px(768)),
2820 "unread: {condition:?}"
2821 );
2822 }
2823 }
2824
2825 #[test]
2826 fn a_query_in_a_script_comment_is_prose() {
2827 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2828 assert!(js_widths(&blank(js, Lang::Js)).is_empty());
2829 }
2830
2831 #[test]
2832 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2833 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2834 let src = blank(css, Lang::Css);
2835 assert_eq!(media_conditions(&src).len(), 1);
2836 }
2837
2838 #[test]
2839 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2840 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2843 let src = blank(css, Lang::Css);
2844 assert_eq!(src.len(), css.len());
2845 let at = css.find("@layer").unwrap();
2846 assert_eq!(&src[at..at + 6], "@layer");
2847 let dropped = drop_layers(css, &["reset"]);
2848 assert!(!dropped.contains("padding"), "{dropped:?}");
2849 }
2850
2851 #[test]
2852 fn a_dropped_layer_keeps_its_important_rules() {
2853 let css =
2857 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2858 let dropped = drop_layers(css, &["reset"]);
2859 assert!(!dropped.contains("padding"), "{dropped:?}");
2860 assert!(dropped.contains(".card"), "{dropped:?}");
2861 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2862 }
2863
2864 #[test]
2865 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2866 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2867 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2868 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2869 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2870 }
2871
2872 #[test]
2873 fn blanking_keeps_lines_and_offsets() {
2874 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2875 let src = blank(css, Lang::Css);
2876 assert_eq!(src.len(), css.len());
2877 assert_eq!(src.lines().count(), css.lines().count());
2878 }
2879
2880 #[test]
2881 fn both_quote_styles_read() {
2882 let want = Density::Touch.media_condition();
2883 for q in ['\'', '"'] {
2884 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2885 let found = touch_density_literals(&src);
2886 assert_eq!(found.len(), 1);
2887 assert_eq!(found[0].1, want);
2888 }
2889 }
2890
2891 #[test]
2892 fn a_file_list_skips_the_sheets_named_generated() {
2893 let dir = frontend("vocab-files");
2894 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2895 write(&dir, "css/app.css", "body { color: red; }\n");
2896 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2897 Vocabulary::files(&files)
2898 .generated(&["layout.css"])
2899 .run(&Emit::default());
2900 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2901 .unwrap_err();
2902 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2903 assert!(msg.contains("box-shadow"), "got: {msg}");
2904 }
2905}