1use std::path::{Path, PathBuf};
15
16use super::rerun;
17use crate::Emit;
18use crate::css_lex::{Lang, Role, is_ident, lex, matching_close};
19use makeover_geometry::{Density, SizeClass};
20
21const CONST_NAME: &str = "TOUCH_DENSITY";
24
25const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
31
32pub fn check_touch_density(js_dir: impl AsRef<Path>) {
53 let js_dir = js_dir.as_ref();
54 let want = Density::Touch.media_condition();
55 let mut wrong: Vec<String> = Vec::new();
56 let mut found = 0usize;
57
58 let files = js_files(js_dir);
59 for path in &files {
60 let raw = read(path);
61 let src = blank(&raw, Lang::Js);
65 let name = shown(path, Some(js_dir));
66
67 for (offset, literal) in touch_density_literals(&src) {
68 found += 1;
69 if literal != want {
70 wrong.push(format!(
71 " {name}:{} {CONST_NAME} = '{literal}'",
72 line_of(&src, offset)
73 ));
74 }
75 }
76
77 for needle in SNIFFS {
78 if let Some(offset) = src.find(needle) {
79 wrong.push(format!(
80 " {name}:{} {needle} -- device sniff, not a density question",
81 line_of(&src, offset)
82 ));
83 }
84 }
85 }
86
87 assert!(
88 found > 0,
89 "no {CONST_NAME} literal found under {}.\n\n\
90 A frontend that asks whether it is being touched states\n\
91 makeover_geometry::Density::Touch's media condition in a const of that\n\
92 name, and this check exists to keep every copy equal to it. If the\n\
93 const was renamed, rename it back rather than dropping the check; if\n\
94 this frontend genuinely asks no density question, drop the call.",
95 js_dir.display()
96 );
97
98 assert!(
99 wrong.is_empty(),
100 "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
101 Density::Touch.media_condition() is: {want}\n\n\
102 Wrong:\n{}\n\n\
103 Fix the JS to state the crate's string. Never widen it to catch a\n\
104 device the query misses: density is what is pointing at the screen,\n\
105 and a laptop with a touchscreen and a mouse is a pointer device.",
106 wrong.join("\n")
107 );
108
109 for path in &files {
110 rerun(path);
111 }
112 rerun(js_dir);
116}
117
118fn read(path: &Path) -> String {
120 std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
121}
122
123fn shown(path: &Path, root: Option<&Path>) -> String {
126 root.and_then(|root| path.strip_prefix(root).ok())
127 .unwrap_or(path)
128 .display()
129 .to_string()
130}
131
132fn frontend_css(frontend: &Path) -> (PathBuf, Vec<PathBuf>) {
135 let css = frontend.join("css");
136 rerun(&css);
137 let files = files_with_extension(&css, "css");
138 (css, files)
139}
140
141fn js_files(dir: &Path) -> Vec<PathBuf> {
143 files_with_extension(dir, "js")
144}
145
146fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
153 let mut out = Vec::new();
154 let mut stack = vec![dir.to_path_buf()];
155 while let Some(d) = stack.pop() {
156 for entry in std::fs::read_dir(&d)
157 .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
158 .flatten()
159 {
160 let path = entry.path();
161 if path.is_dir() {
162 stack.push(path);
163 } else if path.extension().is_some_and(|x| x == ext) {
164 out.push(path);
165 }
166 }
167 }
168 out.sort();
169 out
170}
171
172fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
179 let mut out = Vec::new();
180 let mut at = 0;
181 while let Some(i) = src[at..].find(CONST_NAME) {
182 let start = at + i;
183 at = start + CONST_NAME.len();
184 if start > 0 && is_ident_byte(src.as_bytes()[start - 1]) {
187 continue;
188 }
189 if src.as_bytes().get(at).copied().is_some_and(is_ident_byte) {
190 continue;
191 }
192 let rest = &src[at..];
194 let lead = rest.len() - rest.trim_start().len();
195 let rest = &rest[lead..];
196 if !rest.starts_with('=') {
197 continue;
198 }
199 let rest = &rest[1..];
202 let gap = rest.len() - rest.trim_start().len();
203 let rest = &rest[gap..];
204 let Some(quote @ ('\'' | '"' | '`')) = rest.chars().next() else {
205 continue;
206 };
207 let body = at + lead + 1 + gap + quote.len_utf8();
208 if let Some(j) = src[body..].find(quote) {
209 out.push((start, &src[body..body + j]));
210 at = body + j + 1;
211 }
212 }
213 out
214}
215
216fn is_ident_byte(b: u8) -> bool {
219 b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
220}
221
222fn line_of(src: &str, offset: usize) -> usize {
223 src[..offset].matches('\n').count() + 1
224}
225
226#[derive(Debug, Clone)]
228enum Source {
229 Frontend(PathBuf),
231 Files(Vec<PathBuf>),
233}
234
235impl Source {
236 fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
237 Self::Files(paths.iter().map(|p| p.as_ref().to_path_buf()).collect())
238 }
239}
240
241#[derive(Debug, Clone)]
265#[must_use = "a check does nothing until it is run"]
266pub struct Breakpoints {
267 source: Source,
268 tuning: Vec<u16>,
269}
270
271impl Breakpoints {
272 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
275 Self {
276 source: Source::Frontend(frontend.as_ref().to_path_buf()),
277 tuning: Vec::new(),
278 }
279 }
280
281 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
291 Self {
292 source: Source::files(paths),
293 tuning: Vec::new(),
294 }
295 }
296
297 pub fn tuning(mut self, widths: &[u16]) -> Self {
307 self.tuning.extend_from_slice(widths);
308 self
309 }
310
311 pub fn run(&self) {
321 match &self.source {
322 Source::Frontend(frontend) => {
323 let (_, mut files) = frontend_css(frontend);
324 let js = frontend.join("js");
327 rerun(&js);
328 if js.is_dir() {
329 files.extend(js_files(&js));
330 }
331 check_paths(&files, &self.tuning, Some(frontend));
332 }
333 Source::Files(paths) => check_paths(paths, &self.tuning, None),
334 }
335 }
336}
337
338fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
340 let allowed = allowed_widths(tuning_widths);
341 let edges = size_class_widths();
342 let mut stale: Vec<String> = Vec::new();
343
344 for path in paths {
345 let raw = read(path);
346 let name = shown(path, root);
347
348 if path.extension().is_some_and(|x| x == "js") {
349 for (offset, bound) in js_widths(&blank(&raw, Lang::Js)) {
351 if !bound.is_allowed(&allowed, &edges) {
352 stale.push(format!(
353 " {name}:{} ({})",
354 line_of(&raw, offset),
355 bound.width
356 ));
357 }
358 }
359 continue;
360 }
361
362 let src = blank(&raw, Lang::Css);
365 for (offset, condition) in media_conditions(&src) {
366 for bound in media_bounds(condition) {
367 if !bound.is_allowed(&allowed, &edges) {
368 stale.push(format!(
369 " {name}:{} @media{condition} ({})",
370 line_of(&src, offset),
371 bound.width
372 ));
373 }
374 }
375 }
376 }
377
378 assert!(
379 stale.is_empty(),
380 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
381 Allowed: {allowed:?}\n\
382 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
383 Stale:\n{}\n\n\
384 Size classes are written in range syntax, `(width < 600px)`, because\n\
385 `(max-width: 599px)` leaves 599.5px in no class. If a size class moved,\n\
386 update these to match. If one of these is a new\n\
387 tuning width inside the wide shell rather than a shell boundary, add it\n\
388 to the caller's tuning list with a note saying what it tunes.\n\n\
389 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
390 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
391 clamp(). A threshold is for what appears and disappears.",
392 allowed
393 .iter()
394 .filter(|px| !tuning_widths.contains(px))
395 .collect::<Vec<_>>(),
396 stale.join("\n")
397 );
398
399 for path in paths {
400 rerun(path);
401 }
402}
403
404fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
410 let mut widths = size_class_widths();
411 widths.extend_from_slice(tuning_widths);
412 widths.sort_unstable();
413 widths.dedup();
414 widths
415}
416
417fn size_class_widths() -> Vec<u16> {
422 let mut widths: Vec<u16> = SizeClass::all()
423 .iter()
424 .flat_map(|c| media_widths(&c.media_condition()))
425 .map(|width| match width {
426 Width::Px(px) => px,
427 Width::Other(raw) => {
428 panic!("makeover-geometry wrote a size-class width this check cannot read: {raw}")
429 }
430 })
431 .collect();
432 widths.sort_unstable();
433 widths.dedup();
434 widths
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
439enum Width {
440 Px(u16),
443 Other(String),
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
451struct Bound {
452 width: Width,
453 direction: Direction,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459enum Direction {
460 Edge,
463 Straddles,
466}
467
468impl Bound {
469 fn is_allowed(&self, allowed: &[u16], edges: &[u16]) -> bool {
474 match &self.width {
475 Width::Px(px) => {
476 allowed.contains(px)
477 && !(edges.contains(px) && self.direction == Direction::Straddles)
478 }
479 Width::Other(_) => false,
480 }
481 }
482}
483
484impl std::fmt::Display for Width {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 match self {
487 Self::Px(px) => write!(f, "{px}px"),
488 Self::Other(raw) => f.write_str(raw),
489 }
490 }
491}
492
493fn media_widths(condition: &str) -> Vec<Width> {
495 media_bounds(condition)
496 .into_iter()
497 .map(|bound| bound.width)
498 .collect()
499}
500
501fn media_bounds(condition: &str) -> Vec<Bound> {
511 width_groups(condition)
512 .into_iter()
513 .flat_map(|(_, group)| lengths(group))
514 .collect()
515}
516
517fn width_groups(src: &str) -> Vec<(usize, &str)> {
521 let mut out: Vec<(usize, &str)> = Vec::new();
522 let mut stack: Vec<usize> = Vec::new();
523 for (i, c) in src.char_indices() {
524 match c {
525 '(' => stack.push(i),
526 ')' => {
527 if let Some(start) = stack.pop() {
528 let inner = &src[start + 1..i];
529 let encloses_read = out.iter().any(|&(inner_start, _)| inner_start > start);
534 if names_width(inner) && !encloses_read {
535 out.push((start, inner));
536 }
537 }
538 }
539 _ => {}
540 }
541 }
542 out
543}
544
545fn names_width(inner: &str) -> bool {
549 width_token_at(inner).is_some()
550}
551
552fn width_token_at(inner: &str) -> Option<usize> {
556 inner.match_indices("width").find_map(|(at, _)| {
557 let before = &inner[..at];
558 let prefix_ok = before.is_empty()
559 || before.ends_with("min-")
560 || before.ends_with("max-")
561 || before.chars().next_back().is_some_and(|c| !is_ident(c));
562 let after = inner[at + "width".len()..].chars().next();
563 (prefix_ok && after.is_none_or(|c| !is_ident(c))).then_some(at)
564 })
565}
566
567fn lengths(group: &str) -> Vec<Bound> {
570 let bytes = group.as_bytes();
571 let width_at = width_token_at(group);
572 let mut out = Vec::new();
573 let mut i = 0;
574 while i < bytes.len() {
575 let prev_ok = i == 0
578 || !(bytes[i - 1].is_ascii_alphanumeric()
579 || bytes[i - 1] == b'-'
580 || bytes[i - 1] == b'.');
581 let starts_number = prev_ok
582 && (bytes[i].is_ascii_digit()
583 || (bytes[i] == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)));
584 if !starts_number {
585 i += 1;
586 continue;
587 }
588 let start = i;
589 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
590 i += 1;
591 }
592 let number = &group[start..i];
593 let unit_start = i;
594 while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
595 i += 1;
596 }
597 let unit = &group[unit_start..i];
598 if unit.is_empty() {
599 continue;
600 }
601 let raw = &group[start..i];
602 let scale = match unit {
603 "px" => Some(1.0),
604 "em" | "rem" => Some(16.0),
605 _ => None,
606 };
607 let px = scale.and_then(|scale| number.parse::<f64>().ok().map(|n| n * scale));
608 let width = match px {
609 Some(px) if px.fract() == 0.0 && px >= 0.0 && px <= f64::from(u16::MAX) => {
610 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
612 Width::Px(px as u16)
613 }
614 _ => Width::Other(raw.to_string()),
615 };
616 out.push(Bound {
617 width,
618 direction: direction_of(group, start, i, width_at),
619 });
620 }
621 out
622}
623
624fn direction_of(group: &str, start: usize, end: usize, width_at: Option<usize>) -> Direction {
633 let Some(width_at) = width_at else {
634 return Direction::Edge;
635 };
636 let width_end = width_at + "width".len();
637 if start >= width_end {
638 let op = &group[width_end..start];
640 if op.contains("<=") {
641 Direction::Straddles
642 } else if op.contains(">=") || op.contains('<') {
643 Direction::Edge
644 } else if op.contains('>') {
645 Direction::Straddles
646 } else if group[..width_at].ends_with("min-") {
647 Direction::Edge
648 } else {
649 Direction::Straddles
651 }
652 } else {
653 let op = &group[end..width_at];
655 if op.contains("<=") || op.contains('>') {
656 Direction::Edge
657 } else {
658 Direction::Straddles
659 }
660 }
661}
662
663fn media_conditions(css: &str) -> Vec<(usize, &str)> {
671 let lower = css.to_ascii_lowercase();
672 let mut out = Vec::new();
673 let mut at = 0;
674 while let Some(rel) = lower[at..].find('@') {
675 let start = at + rel;
676 let kw_start = start + 1;
677 let kw_end = css[kw_start..]
678 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
679 .map_or(css.len(), |i| kw_start + i);
680 let terminator = match &lower[kw_start..kw_end] {
681 "media" | "container" => '{',
682 "custom-media" | "import" => ';',
683 _ => {
684 at = start + 1;
685 continue;
686 }
687 };
688 match css[kw_end..].find(terminator) {
689 Some(j) => {
690 out.push((start, &css[kw_end..kw_end + j]));
691 at = kw_end + j + 1;
692 }
693 None => break,
694 }
695 }
696 out
697}
698
699fn js_widths(src: &str) -> Vec<(usize, Bound)> {
707 width_groups(src)
708 .into_iter()
709 .filter(|(_, group)| is_feature(group))
710 .flat_map(|(offset, group)| lengths(group).into_iter().map(move |b| (offset, b)))
711 .collect()
712}
713
714fn is_feature(group: &str) -> bool {
718 let group = group.trim();
719 let colon = ["min-width", "max-width", "width"].iter().any(|name| {
720 group
721 .strip_prefix(name)
722 .is_some_and(|rest| rest.trim_start().starts_with(':'))
723 });
724 colon || group.contains(['<', '>'])
725}
726
727fn blank(src: &str, lang: Lang) -> String {
735 let mut out = String::with_capacity(src.len());
736 for (c, role) in lex(src, lang) {
737 if role == Role::Code || (role == Role::Text && lang == Lang::Js) {
738 out.push(c);
739 } else if c == '\n' {
740 out.push('\n');
741 } else {
742 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
743 }
744 }
745 out
746}
747
748fn drop_layers(css: &str, names: &[&str]) -> String {
761 if names.is_empty() {
762 return css.to_owned();
763 }
764 let scan = blank(css, Lang::Css);
765 let scan = scan.as_bytes();
766 let mut out = css.as_bytes().to_vec();
767 let mut at = 0;
768 while let Some(found) = find_at_layer(scan, at) {
769 let mut i = found + b"@layer".len();
770 while i < scan.len() && scan[i].is_ascii_whitespace() {
771 i += 1;
772 }
773 let start = i;
774 while i < scan.len()
775 && (scan[i].is_ascii_alphanumeric() || matches!(scan[i], b'-' | b'_' | b'.'))
776 {
777 i += 1;
778 }
779 let name = &css[start..i];
780 while i < scan.len() && scan[i].is_ascii_whitespace() {
781 i += 1;
782 }
783 if i >= scan.len() || scan[i] != b'{' || !layer_is_beneath(name, names) {
784 at = found + 1;
785 continue;
786 }
787 match matching_close(scan, i, b'{', b'}') {
788 Some(close) => {
789 blank_dropped_layer(&mut out, scan, found, i, close);
790 at = close + 1;
791 }
792 None => {
793 blank_span(&mut out, found, scan.len());
794 at = scan.len();
795 }
796 }
797 }
798 String::from_utf8(out).unwrap_or_else(|_| css.to_owned())
799}
800
801fn layer_is_beneath(name: &str, names: &[&str]) -> bool {
805 names.iter().any(|beneath| {
806 name == *beneath
807 || name
808 .strip_prefix(beneath)
809 .is_some_and(|rest| rest.starts_with('.'))
810 })
811}
812
813fn find_at_layer(haystack: &[u8], from: usize) -> Option<usize> {
816 let needle = b"@layer";
817 let mut i = from;
818 while i + needle.len() <= haystack.len() {
819 if haystack[i..i + needle.len()].eq_ignore_ascii_case(needle) {
820 return Some(i);
821 }
822 i += 1;
823 }
824 None
825}
826
827fn blank_span(out: &mut [u8], start: usize, end: usize) {
829 for byte in &mut out[start..end] {
830 if *byte != b'\n' {
831 *byte = b' ';
832 }
833 }
834}
835
836fn blank_dropped_layer(out: &mut [u8], scan: &[u8], head: usize, open: usize, close: usize) {
844 blank_span(out, head, open + 1);
845 blank_dropped_content(out, scan, open + 1, close);
846 blank_span(out, close, close + 1);
847}
848
849fn blank_dropped_content(out: &mut [u8], scan: &[u8], start: usize, end: usize) {
855 let mut i = start;
856 while i < end {
857 let mut open = i;
858 while open < end && scan[open] != b'{' && scan[open] != b';' {
859 open += 1;
860 }
861 if open >= end {
862 blank_span(out, i, end);
863 break;
864 }
865 if scan[open] == b';' {
866 blank_span(out, i, open + 1);
867 i = open + 1;
868 continue;
869 }
870 let Some(close) = matching_close(&scan[..end], open, b'{', b'}') else {
871 blank_span(out, i, end);
872 break;
873 };
874 let is_at_rule = scan[i..open]
875 .iter()
876 .position(|b| !b.is_ascii_whitespace())
877 .is_some_and(|k| scan[i + k] == b'@');
878 if is_at_rule {
879 blank_dropped_content(out, scan, open + 1, close);
880 } else if !has_important(&scan[open + 1..close]) {
881 blank_span(out, i, close + 1);
882 }
883 i = close + 1;
884 }
885}
886
887fn has_important(bytes: &[u8]) -> bool {
890 let keyword = b"important";
891 let mut i = 0;
892 while i < bytes.len() {
893 if bytes[i] == b'!' {
894 let mut k = i + 1;
895 while k < bytes.len() && bytes[k].is_ascii_whitespace() {
896 k += 1;
897 }
898 if k + keyword.len() <= bytes.len()
899 && bytes[k..k + keyword.len()].eq_ignore_ascii_case(keyword)
900 {
901 return true;
902 }
903 }
904 i += 1;
905 }
906 false
907}
908
909fn layers_ordered_below_makeover(css: &str) -> Vec<String> {
917 let scan = blank(css, Lang::Css);
918 let mut below = Vec::new();
919 for statement in scan.split(';') {
920 let Some(at) = statement.rfind("@layer") else {
921 continue;
922 };
923 let names: Vec<&str> = statement[at + "@layer".len()..]
924 .split(',')
925 .map(str::trim)
926 .collect();
927 if names.iter().any(|name| name.contains(['{', '}'])) {
928 continue;
929 }
930 if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
931 below.extend(names[..position].iter().map(|name| (*name).to_string()));
932 }
933 }
934 below
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum Allow<'a> {
945 Class {
947 class: &'a str,
949 property: &'a str,
951 },
952 Element {
955 element: &'a str,
957 class: &'a str,
959 property: &'a str,
961 },
962}
963
964impl<'a> Allow<'a> {
965 pub const fn class(class: &'a str, property: &'a str) -> Self {
967 Self::Class { class, property }
968 }
969
970 pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
972 Self::Element {
973 element,
974 class,
975 property,
976 }
977 }
978}
979
980#[derive(Debug, Clone)]
1057#[must_use = "a check does nothing until it is run"]
1058pub struct Vocabulary<'a> {
1059 source: Source,
1060 generated: Vec<&'a str>,
1061 upstream: Vec<&'a str>,
1062 allowed: Vec<Allow<'a>>,
1063 beneath: Vec<&'a str>,
1064}
1065
1066impl<'a> Vocabulary<'a> {
1067 pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
1069 Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
1070 }
1071
1072 pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
1078 Self::from(Source::files(paths))
1079 }
1080
1081 fn from(source: Source) -> Self {
1082 Self {
1083 source,
1084 generated: Vec::new(),
1085 upstream: Vec::new(),
1086 allowed: Vec::new(),
1087 beneath: Vec::new(),
1088 }
1089 }
1090
1091 pub fn generated(mut self, names: &[&'a str]) -> Self {
1096 self.generated.extend_from_slice(names);
1097 self
1098 }
1099
1100 pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
1110 self.upstream.extend_from_slice(sheets);
1111 self
1112 }
1113
1114 pub fn beneath(mut self, layers: &[&'a str]) -> Self {
1124 self.beneath.extend_from_slice(layers);
1125 self
1126 }
1127
1128 pub fn allow(mut self, allow: Allow<'a>) -> Self {
1130 self.allowed.push(allow);
1131 self
1132 }
1133
1134 pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
1136 self.allowed.extend(allows);
1137 self
1138 }
1139
1140 pub fn run(&self, opts: &Emit) {
1151 let classes: Vec<(&str, &str)> = self
1152 .allowed
1153 .iter()
1154 .filter_map(|allow| match *allow {
1155 Allow::Class { class, property } => Some((class, property)),
1156 Allow::Element { .. } => None,
1157 })
1158 .collect();
1159 let elements: Vec<(&str, &str, &str)> = self
1160 .allowed
1161 .iter()
1162 .filter_map(|allow| match *allow {
1163 Allow::Element {
1164 element,
1165 class,
1166 property,
1167 } => Some((element, class, property)),
1168 Allow::Class { .. } => None,
1169 })
1170 .collect();
1171 match &self.source {
1172 Source::Frontend(frontend) => {
1173 let (css, files) = frontend_css(frontend);
1174 let files: Vec<PathBuf> = files
1175 .into_iter()
1176 .filter(|p| !self.generated.contains(&shown(p, Some(&css)).as_str()))
1177 .collect();
1178 check_vocabulary_paths(
1179 &files,
1180 opts,
1181 Some(frontend),
1182 &self.upstream,
1183 &self.beneath,
1184 &classes,
1185 &elements,
1186 );
1187 }
1188 Source::Files(paths) => {
1189 let files: Vec<PathBuf> = paths
1190 .iter()
1191 .filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
1192 .cloned()
1193 .collect();
1194 check_vocabulary_paths(
1195 &files,
1196 opts,
1197 None,
1198 &self.upstream,
1199 &self.beneath,
1200 &classes,
1201 &elements,
1202 );
1203 }
1204 }
1205 }
1206}
1207
1208fn check_vocabulary_paths(
1210 paths: &[PathBuf],
1211 opts: &Emit,
1212 root: Option<&Path>,
1213 upstream: &[&str],
1214 beneath: &[&str],
1215 allowed: &[(&str, &str)],
1216 allowed_elements: &[(&str, &str, &str)],
1217) {
1218 let sheet = crate::stylesheet(opts);
1219 let makeover = crate::vocabulary::declarations_by_class(&sheet);
1220 let mut owned = makeover.clone();
1223 for sheet in upstream {
1224 for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
1225 owned.entry(class).or_default().extend(properties);
1226 }
1227 }
1228 let makeover_variants = crate::vocabulary::variants_by_class(&sheet);
1231 let mut owned_variants = makeover_variants.clone();
1232 for sheet in upstream {
1233 for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
1234 let entry = owned_variants.entry(class).or_default();
1235 for (arm, properties) in arms {
1236 entry.entry(arm).or_default().extend(properties);
1237 }
1238 }
1239 }
1240 let mut cancels: Vec<String> = Vec::new();
1241 let mut clashes: Vec<String> = Vec::new();
1242 let mut seen: Vec<(String, String)> = Vec::new();
1243 let mut element_clashes: Vec<String> = Vec::new();
1244 let mut element_seen: Vec<(String, String, String)> = Vec::new();
1245
1246 for path in paths {
1247 rerun(path);
1248 let raw = read(path);
1249 let name = shown(path, root);
1250 let (generated, variants) = if upstream.contains(&raw.as_str()) {
1253 (&makeover, &makeover_variants)
1254 } else {
1255 (&owned, &owned_variants)
1256 };
1257 let raw = drop_layers(&raw, beneath);
1260 let local = crate::vocabulary::declarations_by_class(&raw);
1263 for (class, properties) in &local {
1264 let Some(theirs) = generated.get(class) else {
1265 continue;
1266 };
1267 for property in properties.intersection(theirs) {
1268 seen.push((class.clone(), property.clone()));
1269 if allowed.contains(&(class.as_str(), property.as_str())) {
1270 continue;
1271 }
1272 clashes.push(format!(" {name} .{class} {{ {property} }}"));
1273 }
1274 }
1275
1276 let own_below = layers_ordered_below_makeover(&raw);
1287 let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
1288 let above = drop_layers(&raw, &own_below);
1289 let bare = crate::vocabulary::bare_declarations(&above);
1290 let restated = crate::vocabulary::mentions_by_arm(&raw);
1291 for (class, properties) in &bare {
1292 let Some(arms) = variants.get(class) else {
1293 continue;
1294 };
1295 for property in properties {
1296 let lost: Vec<&str> = arms
1297 .iter()
1298 .filter(|(arm, theirs)| {
1299 theirs.contains(property)
1300 && !restated
1301 .get(arm.as_str())
1302 .is_some_and(|mentioned| mentioned.contains(property))
1303 })
1304 .map(|(arm, _)| arm.as_str())
1305 .collect();
1306 if lost.is_empty() {
1307 continue;
1308 }
1309 cancels.push(format!(
1310 " {name} .{class} {{ {property} }} cancels {}",
1311 lost.iter()
1312 .map(|arm| format!("{arm} {{ {property} }}"))
1313 .collect::<Vec<_>>()
1314 .join(", ")
1315 ));
1316 }
1317 }
1318
1319 let mentioned = crate::vocabulary::mentions_by_class(&raw);
1325 let by_element = crate::vocabulary::declarations_by_element(&raw);
1326 for (element, properties) in &by_element {
1327 for class in crate::vocabulary::classes_for_element(element, opts) {
1328 let Some(theirs) = generated.get(&class) else {
1329 continue;
1330 };
1331 for (property, rank) in properties {
1332 if !theirs.contains(property) {
1333 continue;
1334 }
1335 let spoken_for = mentioned
1339 .get(&class)
1340 .and_then(|properties| properties.get(property))
1341 .is_some_and(|theirs| theirs >= rank);
1342 if spoken_for {
1343 continue;
1344 }
1345 element_seen.push((element.clone(), class.clone(), property.clone()));
1346 if allowed_elements.contains(&(
1347 element.as_str(),
1348 class.as_str(),
1349 property.as_str(),
1350 )) {
1351 continue;
1352 }
1353 element_clashes.push(format!(
1354 " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
1355 ));
1356 }
1357 }
1358 }
1359 }
1360
1361 assert!(
1362 cancels.is_empty(),
1363 "{} hand-written rule(s) on a bare generated class set a property the \
1364 design system varies on that class by state, tone or context. The app \
1365 sheet sits in a later cascade layer than @layer makeover, and layers \
1366 resolve before specificity, so each of these beats every variant named \
1367 beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
1368 cancelled variant in the app's sheet on its own arm, after the bare rule \
1369 (`color: revert-layer` hands the design system's value back). That is \
1370 the remedy the cascade allows; an allowed-list entry does not cover \
1371 this, because the reviewed pair is the class and the variants are \
1372 what it silently takes.",
1373 cancels.len(),
1374 cancels.join("\n")
1375 );
1376
1377 assert!(
1378 clashes.is_empty(),
1379 "{} hand-written declaration(s) take a property the generated stylesheet, \
1380 or an upstream sheet named to this check, already sets on the same \
1381 class. App CSS wins over @layer makeover, whether by a later layer or \
1382 by being unlayered, so each of these wins over the design system \
1383 silently:\n{}\n\nDelete the declaration, or, if \
1384 it is a deliberate pairing on a different selector arm, add \
1385 (class, property) to this check's allowed list and say why beside it. \
1386 Count the consumers before deciding a divergence is worth keeping.",
1387 clashes.len(),
1388 clashes.join("\n")
1389 );
1390
1391 assert!(
1392 element_clashes.is_empty(),
1393 "{} hand-written element rule(s) take a property the generated \
1394 stylesheet sets on a class that element carries. App CSS wins over \
1395 @layer makeover, whether by a later layer or by being unlayered, so a \
1396 described component rendered on one of these elements loses the \
1397 design system's version of that property silently, which can draw a \
1398 destructive act like an ordinary one:\n{}\n\nHand the \
1399 property back on the arms makeover paints \
1400 (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
1401 rule so it stops reaching described markup, or add \
1402 (element, class, property) to this check's allowed-elements list and \
1403 say why beside it.",
1404 element_clashes.len(),
1405 element_clashes.join("\n")
1406 );
1407
1408 assert_no_stale("allowed", allowed, |(class, property)| {
1409 seen.contains(&((*class).to_string(), (*property).to_string()))
1410 });
1411 assert_no_stale(
1412 "allowed-elements",
1413 allowed_elements,
1414 |(element, class, property)| {
1415 element_seen.contains(&(
1416 (*element).to_string(),
1417 (*class).to_string(),
1418 (*property).to_string(),
1419 ))
1420 },
1421 );
1422}
1423
1424fn assert_no_stale<T: std::fmt::Debug>(list: &str, entries: &[T], used: impl Fn(&T) -> bool) {
1426 let stale: Vec<&T> = entries.iter().filter(|entry| !used(entry)).collect();
1427 assert!(
1428 stale.is_empty(),
1429 "the {list} list declares {stale:?}, which no longer collides with \
1430 anything. Delete the entries: an exception nobody is using is where the \
1431 next real collision lands and reads as company."
1432 );
1433}
1434
1435fn haystack_names(haystack: &str, class: &str) -> bool {
1439 haystack.match_indices(class).any(|(at, found)| {
1440 haystack[..at]
1441 .chars()
1442 .next_back()
1443 .is_none_or(|c| !is_ident(c))
1444 && haystack[at + found.len()..]
1445 .chars()
1446 .next()
1447 .is_none_or(|c| !is_ident(c))
1448 })
1449}
1450
1451pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
1475 let generated = crate::vocabulary::names(opts);
1476 let mut haystack = String::new();
1477 for path in markup {
1478 let path = path.as_ref();
1479 rerun(path);
1480 haystack.push_str(&read(path));
1481 haystack.push('\n');
1482 }
1483
1484 const LADDERS: &[&str] = &[
1490 "min-",
1491 "short-",
1492 "stacks-",
1493 "tiles-",
1494 "drop-optional-",
1495 "drop-secondary-",
1496 ];
1497 let rung_of = |class: &str| -> Option<&'static str> {
1498 let name = class.strip_prefix(opts.class_prefix)?;
1499 LADDERS.iter().copied().find(|ladder| {
1500 name.strip_prefix(ladder)
1501 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1502 })
1503 };
1504 let mut unused: Vec<String> = generated
1505 .iter()
1506 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1507 .cloned()
1508 .collect();
1509 for ladder in LADDERS {
1510 let rungs: Vec<&String> = generated
1511 .iter()
1512 .filter(|class| rung_of(class) == Some(ladder))
1513 .collect();
1514 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1515 unused.push(format!("{}{ladder}N", opts.class_prefix));
1516 }
1517 }
1518
1519 assert!(
1520 unused.len() <= high_water,
1521 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1522 The vocabulary grew or the markup stopped using it:\n{}",
1523 unused.len(),
1524 generated.len(),
1525 high_water,
1526 unused
1527 .iter()
1528 .map(|c| format!(" .{c}"))
1529 .collect::<Vec<_>>()
1530 .join("\n")
1531 );
1532
1533 if unused.len() < high_water {
1534 println!(
1535 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1536 lower the seal so it cannot grow back",
1537 unused.len(),
1538 high_water
1539 );
1540 }
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545 use super::*;
1546 use crate::test_support::scratch;
1547 use std::fmt::Write as _;
1548
1549 fn write(dir: &Path, name: &str, src: &str) {
1550 if let Some(parent) = dir.join(name).parent() {
1551 std::fs::create_dir_all(parent).unwrap();
1552 }
1553 std::fs::write(dir.join(name), src).unwrap();
1554 }
1555
1556 fn declaring() -> String {
1557 format!(
1558 "const {CONST_NAME} = '{}';\n",
1559 Density::Touch.media_condition()
1560 )
1561 }
1562
1563 #[test]
1564 fn the_crates_own_string_passes() {
1565 let dir = scratch("ok");
1566 write(&dir, "touch.js", &declaring());
1567 check_touch_density(&dir);
1568 }
1569
1570 #[test]
1571 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1572 fn a_drifted_literal_fails() {
1573 let dir = scratch("drift");
1574 write(&dir, "touch.js", &declaring());
1575 write(
1576 &dir,
1577 "haptics.js",
1578 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1579 );
1580 check_touch_density(&dir);
1581 }
1582
1583 #[test]
1584 #[should_panic(expected = "device sniff")]
1585 fn the_sniff_cannot_come_back() {
1586 let dir = scratch("sniff");
1587 write(&dir, "touch.js", &declaring());
1588 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1589 check_touch_density(&dir);
1590 }
1591
1592 #[test]
1593 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1594 fn a_frontend_that_states_nothing_fails() {
1595 let dir = scratch("empty");
1596 write(&dir, "app.js", "export const x = 1;\n");
1597 check_touch_density(&dir);
1598 }
1599
1600 #[test]
1601 fn a_use_site_is_not_a_declaration() {
1602 let src =
1606 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1607 assert!(
1608 touch_density_literals(&src).is_empty(),
1609 "{:?}",
1610 touch_density_literals(&src)
1611 );
1612 }
1613
1614 #[test]
1615 fn whitespace_and_backtick_literals_read() {
1616 let want = Density::Touch.media_condition();
1617 for src in [
1618 format!("const {CONST_NAME}='{want}';\n"),
1619 format!("const {CONST_NAME} = `{want}`;\n"),
1620 format!("const {CONST_NAME} =\n '{want}';\n"),
1621 ] {
1622 let found = touch_density_literals(&src);
1623 assert_eq!(found.len(), 1, "src: {src:?}");
1624 assert_eq!(found[0].1, want, "src: {src:?}");
1625 }
1626 }
1627
1628 #[test]
1629 fn a_longer_identifier_is_not_the_const() {
1630 let want = Density::Touch.media_condition();
1632 for src in [
1633 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1634 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1635 ] {
1636 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1637 }
1638 }
1639
1640 #[test]
1641 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1642 fn a_commented_out_declaration_is_not_a_declaration() {
1643 let dir = scratch("commented-decl");
1646 write(
1647 &dir,
1648 "touch.js",
1649 &format!("// {}\n", declaring().trim_end()),
1650 );
1651 check_touch_density(&dir);
1652 }
1653
1654 #[test]
1655 fn a_sniff_named_in_a_comment_is_not_a_use() {
1656 let dir = scratch("sniff-comment");
1658 write(&dir, "touch.js", &declaring());
1659 write(
1660 &dir,
1661 "note.js",
1662 "// never read ontouchstart or maxTouchPoints here\n",
1663 );
1664 check_touch_density(&dir);
1665 }
1666
1667 #[test]
1668 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1669 fn a_wrong_literal_without_spaces_is_caught() {
1670 let dir = scratch("loose-drift");
1672 write(&dir, "touch.js", &declaring());
1673 write(
1674 &dir,
1675 "haptics.js",
1676 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1677 );
1678 check_touch_density(&dir);
1679 }
1680
1681 #[test]
1682 fn nested_files_are_read() {
1683 let dir = scratch("nested");
1686 write(&dir, "touch.js", &declaring());
1687 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1688 let files = js_files(&dir);
1689 assert_eq!(files.len(), 2);
1690 }
1691
1692 #[test]
1693 fn a_non_js_file_is_ignored() {
1694 let dir = scratch("nonjs");
1695 write(&dir, "touch.js", &declaring());
1696 write(&dir, "styles.css", "body { }\n");
1697 assert_eq!(js_files(&dir).len(), 1);
1698 }
1699
1700 fn frontend(name: &str) -> PathBuf {
1701 let dir = scratch(name);
1702 std::fs::create_dir_all(dir.join("css")).unwrap();
1703 std::fs::create_dir_all(dir.join("js")).unwrap();
1704 dir
1705 }
1706
1707 fn boundary() -> u16 {
1709 SizeClass::Medium.min_px()
1710 }
1711
1712 #[test]
1713 fn the_crates_own_boundaries_pass() {
1714 let dir = frontend("bp-ok");
1715 write(
1716 &dir,
1717 "css/styles.css",
1718 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1719 );
1720 Breakpoints::in_frontend(&dir).run();
1721 }
1722
1723 #[test]
1724 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1725 fn a_stale_css_width_fails() {
1726 let dir = frontend("bp-css");
1727 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1728 Breakpoints::in_frontend(&dir).run();
1729 }
1730
1731 #[test]
1732 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1733 fn a_stale_js_width_fails() {
1734 let dir = frontend("bp-js");
1735 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1736 Breakpoints::in_frontend(&dir).run();
1737 }
1738
1739 #[test]
1740 fn a_declared_tuning_width_passes() {
1741 let dir = frontend("bp-tuning");
1742 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1743 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1744 }
1745
1746 #[test]
1747 fn a_container_query_on_a_shell_boundary_passes() {
1748 let dir = frontend("bp-container-ok");
1752 write(
1753 &dir,
1754 "css/styles.css",
1755 &format!("@container screen (width < {}px) {{ }}\n", boundary()),
1756 );
1757 Breakpoints::in_frontend(&dir).run();
1758 }
1759
1760 #[test]
1761 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1762 fn a_stale_container_width_fails() {
1763 let dir = frontend("bp-container-stale");
1764 write(
1765 &dir,
1766 "css/styles.css",
1767 "@container screen (width < 768px) { }\n",
1768 );
1769 Breakpoints::in_frontend(&dir).run();
1770 }
1771
1772 #[test]
1773 fn a_container_inline_size_condition_is_not_a_width_boundary() {
1774 let dir = frontend("bp-container-inline");
1777 write(
1778 &dir,
1779 "css/styles.css",
1780 "@container tab-strip (inline-size < 36rem) { }\n",
1781 );
1782 Breakpoints::in_frontend(&dir).run();
1783 }
1784
1785 #[test]
1786 fn a_width_in_a_comment_is_prose() {
1787 let dir = frontend("bp-comment");
1790 write(
1791 &dir,
1792 "css/styles.css",
1793 "/* was @media (max-width: 768px) until the size classes landed */\n",
1794 );
1795 Breakpoints::in_frontend(&dir).run();
1796 }
1797
1798 #[test]
1799 fn an_unparenthesized_width_is_not_a_breakpoint() {
1800 let dir = frontend("bp-inline");
1804 write(
1805 &dir,
1806 "js/style.js",
1807 "el.style.cssText = 'max-width: 320px; display: block';\n",
1808 );
1809 Breakpoints::in_frontend(&dir).run();
1810 }
1811
1812 #[test]
1813 fn nested_css_is_read() {
1814 let dir = frontend("bp-nested");
1817 write(
1818 &dir,
1819 "css/screens/detail.css",
1820 "@media (max-width: 768px) { }\n",
1821 );
1822 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1823 assert!(found.is_err(), "a nested stylesheet must be scanned");
1824 }
1825
1826 #[test]
1827 fn a_css_only_frontend_scans_without_a_js_directory() {
1828 let dir = scratch("bp-css-only");
1831 write(
1832 &dir,
1833 "css/styles.css",
1834 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1835 );
1836 Breakpoints::in_frontend(&dir).run();
1837 }
1838
1839 #[test]
1840 fn a_named_list_is_checked() {
1841 let dir = frontend("bp-list");
1842 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1843 let listed = dir.join("css/style.css");
1844 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1845 let msg = err.downcast_ref::<String>().expect("String payload");
1846 assert!(msg.contains("style.css:1"), "got: {msg}");
1847 }
1848
1849 #[test]
1850 #[should_panic(expected = "read ")]
1851 fn a_listed_file_that_is_gone_fails() {
1852 let dir = frontend("bp-missing");
1855 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1856 }
1857
1858 #[test]
1859 fn a_listed_js_file_is_parsed_as_script() {
1860 let dir = frontend("bp-list-js");
1863 write(
1864 &dir,
1865 "js/style.js",
1866 "el.style.cssText = 'max-width: 320px';\n",
1867 );
1868 Breakpoints::files(&[dir.join("js/style.js")]).run();
1869 }
1870
1871 #[test]
1872 fn the_error_names_the_file_and_line() {
1873 let dir = frontend("bp-message");
1874 write(
1875 &dir,
1876 "css/styles.css",
1877 "body { }\n@media (max-width: 768px) { }\n",
1878 );
1879 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1880 let msg = err
1881 .downcast_ref::<String>()
1882 .expect("panic payload is a String");
1883 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1884 }
1885
1886 #[test]
1887 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1888 let upstream = ".tile-set { gap: 4px; }\n";
1891 let dir = scratch("vocab-upstream");
1892 write(&dir, "css/quasi.css", upstream);
1893 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1894 let err = std::panic::catch_unwind(|| {
1895 Vocabulary::in_frontend(&dir)
1896 .upstream(&[upstream])
1897 .run(&Emit::default());
1898 })
1899 .unwrap_err();
1900 let msg = err
1901 .downcast_ref::<String>()
1902 .expect("panic payload is a String");
1903 assert!(
1904 msg.contains("css/styles.css .tile-set { gap }"),
1905 "got: {msg}"
1906 );
1907 assert!(
1908 !msg.contains("css/quasi.css"),
1909 "the served copy clashed with itself: {msg}"
1910 );
1911
1912 Vocabulary::in_frontend(&dir).run(&Emit::default());
1915 }
1916
1917 #[test]
1918 fn a_rule_restating_a_generated_class_fails_and_names_it() {
1919 let dir = scratch("vocab-clash");
1920 write(
1923 &dir,
1924 "css/styles.css",
1925 "body { color: red; }\n.card { box-shadow: none; }\n",
1926 );
1927 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1928 .unwrap_err();
1929 let msg = err
1930 .downcast_ref::<String>()
1931 .expect("panic payload is a String");
1932 assert!(msg.contains(".card"), "got: {msg}");
1933 assert!(msg.contains("box-shadow"), "got: {msg}");
1934 assert!(msg.contains("css/styles.css"), "got: {msg}");
1935 }
1936
1937 #[test]
1938 fn an_app_class_of_its_own_is_left_alone() {
1939 let dir = scratch("vocab-clean");
1940 write(
1941 &dir,
1942 "css/styles.css",
1943 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1944 );
1945 Vocabulary::in_frontend(&dir).run(&Emit::default());
1946 }
1947
1948 #[test]
1949 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1950 let dir = scratch("vocab-generated");
1951 let opts = Emit::default();
1952 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1953 Vocabulary::in_frontend(&dir)
1956 .generated(&["layout.css"])
1957 .run(&opts);
1958 }
1959
1960 #[test]
1961 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1962 let dir = scratch("vocab-prefix");
1963 let opts = Emit::new().class_prefix("mo-");
1964 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1967 Vocabulary::in_frontend(&dir).run(&opts);
1968
1969 let dir = scratch("vocab-prefix-clash");
1970 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1971 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1972 }
1973
1974 #[test]
1975 fn a_class_shared_without_a_shared_property_is_left_alone() {
1976 let dir = scratch("vocab-additive");
1977 write(
1980 &dir,
1981 "css/styles.css",
1982 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1983 );
1984 Vocabulary::in_frontend(&dir).run(&Emit::default());
1985 }
1986
1987 #[test]
1988 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1989 let dir = scratch("vocab-allowed");
1990 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
1991 Vocabulary::in_frontend(&dir)
1992 .allow(Allow::class("badge", "padding"))
1993 .run(&Emit::default());
1994
1995 let dir = scratch("vocab-allowed-stale");
1998 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1999 let err = std::panic::catch_unwind(|| {
2000 Vocabulary::in_frontend(&dir)
2001 .allow(Allow::class("badge", "padding"))
2002 .run(&Emit::default());
2003 })
2004 .unwrap_err();
2005 let msg = err
2006 .downcast_ref::<String>()
2007 .expect("panic payload is a String");
2008 assert!(msg.contains("no longer collides"), "got: {msg}");
2009 }
2010
2011 #[test]
2012 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2013 let dir = scratch("vocab-element");
2014 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2018 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2019 .unwrap_err();
2020 let msg = err
2021 .downcast_ref::<String>()
2022 .expect("panic payload is a String");
2023 assert!(msg.contains("select {"), "got: {msg}");
2024 assert!(msg.contains(".field"), "got: {msg}");
2025 assert!(msg.contains("box-shadow"), "got: {msg}");
2026 assert!(msg.contains("css/styles.css"), "got: {msg}");
2027 }
2028
2029 #[test]
2030 fn a_universal_reset_after_the_design_system_is_caught() {
2031 let dir = scratch("vocab-universal");
2035 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2036 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2037 .unwrap_err();
2038 let msg = err
2039 .downcast_ref::<String>()
2040 .expect("panic payload is a String");
2041 assert!(msg.contains("* {"), "got: {msg}");
2042 assert!(msg.contains("padding"), "got: {msg}");
2043 }
2044
2045 #[test]
2046 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2047 let dir = scratch("vocab-universal-beneath");
2048 write(
2049 &dir,
2050 "css/styles.css",
2051 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2052 );
2053 Vocabulary::in_frontend(&dir)
2054 .beneath(&["reset"])
2055 .run(&Emit::default());
2056 }
2057
2058 #[test]
2059 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2060 let dir = scratch("vocab-important-beneath");
2061 write(
2066 &dir,
2067 "css/styles.css",
2068 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2069 );
2070 let err = std::panic::catch_unwind(|| {
2071 Vocabulary::in_frontend(&dir)
2072 .beneath(&["reset"])
2073 .run(&Emit::default());
2074 })
2075 .unwrap_err();
2076 let msg = err.downcast_ref::<String>().expect("a String payload");
2077 assert!(msg.contains("box-shadow"), "got: {msg}");
2078 }
2079
2080 #[test]
2081 fn a_layer_not_named_beneath_is_still_read() {
2082 let dir = scratch("vocab-universal-other-layer");
2083 write(
2084 &dir,
2085 "css/styles.css",
2086 "@layer base {\n * { padding: 0; }\n}\n",
2087 );
2088 let err = std::panic::catch_unwind(|| {
2089 Vocabulary::in_frontend(&dir)
2090 .beneath(&["reset"])
2091 .run(&Emit::default());
2092 })
2093 .unwrap_err();
2094 let msg = err.downcast_ref::<String>().expect("a String payload");
2095 assert!(msg.contains("* {"), "got: {msg}");
2096 }
2097
2098 #[test]
2099 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2100 let dir = scratch("vocab-universal-box");
2101 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2102 Vocabulary::in_frontend(&dir).run(&Emit::default());
2103 }
2104
2105 #[test]
2106 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2107 let dir = scratch("vocab-element-handoff");
2108 write(
2113 &dir,
2114 "css/styles.css",
2115 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2116 );
2117 Vocabulary::in_frontend(&dir).run(&Emit::default());
2118 }
2119
2120 #[test]
2121 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2122 let dir = scratch("vocab-element-spoken-for");
2123 write(
2128 &dir,
2129 "css/styles.css",
2130 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2131 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2132 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2133 );
2134 Vocabulary::in_frontend(&dir)
2135 .allow(Allow::class("field", "box-shadow"))
2136 .run(&Emit::default());
2137 }
2138
2139 #[test]
2140 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2141 let dir = scratch("vocab-element-weak-handoff");
2142 write(
2146 &dir,
2147 "css/styles.css",
2148 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2149 );
2150 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2151 .unwrap_err();
2152 let msg = err
2153 .downcast_ref::<String>()
2154 .expect("panic payload is a String");
2155 assert!(msg.contains(".field"), "got: {msg}");
2156
2157 let dir = scratch("vocab-element-strong-handoff");
2159 write(
2160 &dir,
2161 "css/styles.css",
2162 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2163 );
2164 Vocabulary::in_frontend(&dir).run(&Emit::default());
2165 }
2166
2167 #[test]
2168 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2169 let dir = scratch("vocab-element-scoped");
2170 write(
2174 &dir,
2175 "css/styles.css",
2176 ".wizard select { box-shadow: none; }\n",
2177 );
2178 Vocabulary::in_frontend(&dir).run(&Emit::default());
2179 }
2180
2181 #[test]
2182 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2183 let dir = scratch("vocab-element-unpaired");
2184 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2187 Vocabulary::in_frontend(&dir).run(&Emit::default());
2188 }
2189
2190 #[test]
2191 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2192 let dir = scratch("vocab-cancel-tone");
2197 write(
2198 &dir,
2199 "css/styles.css",
2200 ".button { background: var(--surface-raised); }\n",
2201 );
2202 let err = std::panic::catch_unwind(|| {
2203 Vocabulary::in_frontend(&dir)
2204 .allow(Allow::class("button", "background"))
2205 .run(&Emit::default());
2206 })
2207 .unwrap_err();
2208 let msg = err
2209 .downcast_ref::<String>()
2210 .expect("panic payload is a String");
2211 assert!(
2212 msg.contains(".button { background } cancels"),
2213 "got: {msg}"
2214 );
2215 assert!(
2216 msg.contains(".button[data-tone=\"danger\"] { background }"),
2217 "the message names the variant it cancels: {msg}"
2218 );
2219 assert!(msg.contains("restate"), "and the remedy: {msg}");
2220 }
2221
2222 #[test]
2223 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2224 let opts = Emit::default();
2227 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2228 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2229 for (arm, properties) in &variants["button"] {
2230 if properties.contains("background") {
2231 let loose = arm.replace('"', "'").replace(',', " , ");
2232 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2233 }
2234 }
2235 let dir = scratch("vocab-cancel-restated");
2236 write(&dir, "css/styles.css", &css);
2237 Vocabulary::in_frontend(&dir)
2238 .allow(Allow::class("button", "background"))
2239 .run(&opts);
2240 }
2241
2242 #[test]
2243 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2244 let dir = scratch("vocab-cancel-scoped");
2247 write(
2248 &dir,
2249 "css/styles.css",
2250 ".toolbar .button { color: var(--content); }\n\
2251 .button.quiet { color: var(--content); }\n",
2252 );
2253 Vocabulary::in_frontend(&dir)
2256 .allow(Allow::class("button", "color"))
2257 .run(&Emit::default());
2258 }
2259
2260 #[test]
2261 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2262 let dir = scratch("vocab-cancel-own-order");
2266 write(
2267 &dir,
2268 "css/quasi.css",
2269 "@layer reset, quasi, makeover;\n\
2270 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2271 );
2272 Vocabulary::in_frontend(&dir)
2273 .allow(Allow::class("button", "cursor"))
2274 .run(&Emit::default());
2275
2276 let dir = scratch("vocab-cancel-own-order-above");
2278 write(
2279 &dir,
2280 "css/app.css",
2281 "@layer reset, makeover, app;\n\
2282 @layer app {\n .button { cursor: pointer; }\n}\n",
2283 );
2284 let err = std::panic::catch_unwind(|| {
2285 Vocabulary::in_frontend(&dir)
2286 .allow(Allow::class("button", "cursor"))
2287 .run(&Emit::default());
2288 })
2289 .unwrap_err();
2290 let msg = err
2291 .downcast_ref::<String>()
2292 .expect("panic payload is a String");
2293 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2294 }
2295
2296 #[test]
2297 fn a_handoff_on_the_bare_class_cancels_nothing() {
2298 let dir = scratch("vocab-cancel-handoff");
2299 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2300 Vocabulary::in_frontend(&dir).run(&Emit::default());
2301 }
2302
2303 #[test]
2304 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2305 let dir = scratch("vocab-element-allowed");
2306 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2307 Vocabulary::in_frontend(&dir)
2308 .allow(Allow::element("select", "field", "box-shadow"))
2309 .run(&Emit::default());
2310
2311 let dir = scratch("vocab-element-allowed-stale");
2314 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2315 let err = std::panic::catch_unwind(|| {
2316 Vocabulary::in_frontend(&dir)
2317 .allow(Allow::element("select", "field", "box-shadow"))
2318 .run(&Emit::default());
2319 })
2320 .unwrap_err();
2321 let msg = err
2322 .downcast_ref::<String>()
2323 .expect("panic payload is a String");
2324 assert!(msg.contains("no longer collides"), "got: {msg}");
2325 }
2326
2327 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2329 let mut rungs = 0;
2330 let mut ladders = 0;
2331 for ladder in [
2332 "min-",
2333 "short-",
2334 "stacks-",
2335 "tiles-",
2336 "drop-optional-",
2337 "drop-secondary-",
2338 ] {
2339 let here = names
2340 .iter()
2341 .filter(|c| {
2342 c.strip_prefix(ladder)
2343 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2344 })
2345 .count();
2346 rungs += here;
2347 ladders += usize::from(here > 0);
2348 }
2349 (rungs, ladders)
2350 }
2351
2352 #[test]
2353 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2354 let dir = scratch("vocab-seal");
2355 let opts = Emit::default();
2356 let names = crate::vocabulary::names(&opts);
2357 let (rungs, ladders) = ladders(&names);
2358 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2359 let dead = names.len() - rungs + ladders;
2362 write(&dir, "index.html", "<div></div>\n");
2363 let markup = [dir.join("index.html")];
2364
2365 check_vocabulary_use(&markup, &opts, dead);
2366 assert!(
2367 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2368 "a vocabulary deader than the seal has to fail"
2369 );
2370 }
2371
2372 #[test]
2373 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2374 let opts = Emit::default();
2377 let names = crate::vocabulary::names(&opts);
2378 let (rungs, ladders) = ladders(&names);
2379 let dead = names.len() - rungs + ladders;
2380
2381 let dir = scratch("vocab-ladder");
2382 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2383 let one_rung = [dir.join("index.html")];
2384 check_vocabulary_use(&one_rung, &opts, dead - 2);
2386
2387 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2389 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2390 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2391 }
2392
2393 #[test]
2394 fn a_class_is_named_whole_on_both_sides() {
2395 assert!(haystack_names("class=\"row\"", "row"));
2396 assert!(!haystack_names("class=\"row-flex\"", "row"));
2397 assert!(!haystack_names("class=\"flex-row\"", "row"));
2398 assert!(haystack_names("el.classList.add('row')", "row"));
2399 }
2400
2401 #[test]
2402 fn range_syntax_and_units_are_read() {
2403 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2404 assert_eq!(
2405 media_widths(" (600px <= width < 840px)"),
2406 [Width::Px(600), Width::Px(840)]
2407 );
2408 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2409 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2410 assert_eq!(
2411 media_widths(" (max-width: 599.98px)"),
2412 [Width::Other("599.98px".into())]
2413 );
2414 assert_eq!(
2415 media_widths(" (width < 50vw)"),
2416 [Width::Other("50vw".into())]
2417 );
2418 let found = media_widths(" (min-height: 600px)");
2419 assert!(found.is_empty(), "{found:?}");
2420 let found = media_widths(" (max-device-width: 600px)");
2421 assert!(found.is_empty(), "{found:?}");
2422 let found = media_widths(" (hover: hover) and (pointer: fine)");
2423 assert!(found.is_empty(), "{found:?}");
2424 }
2425
2426 #[test]
2427 fn every_size_class_condition_passes_its_own_check() {
2428 let dir = frontend("bp-classes");
2429 let mut css = String::new();
2430 for class in SizeClass::all() {
2431 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2432 }
2433 write(&dir, "css/styles.css", &css);
2434 write(
2435 &dir,
2436 "js/shell.js",
2437 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2438 );
2439 Breakpoints::in_frontend(&dir).run();
2440 }
2441
2442 #[test]
2443 #[should_panic(expected = "48em")]
2444 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2445 let dir = frontend("bp-em");
2446 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2447 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2448 }
2449
2450 #[test]
2451 #[should_panic(expected = "(width < 768px)")]
2452 fn a_stale_range_width_fails() {
2453 let dir = frontend("bp-range");
2454 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2455 Breakpoints::in_frontend(&dir).run();
2456 }
2457
2458 #[test]
2459 #[should_panic(expected = "599px")]
2460 fn the_old_inclusive_compact_bound_is_stale() {
2461 let dir = frontend("bp-599");
2462 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2463 Breakpoints::in_frontend(&dir).run();
2464 }
2465
2466 #[test]
2467 #[should_panic(expected = "(max-width: 600px)")]
2468 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2469 let dir = frontend("bp-600-inclusive");
2473 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2474 Breakpoints::in_frontend(&dir).run();
2475 }
2476
2477 #[test]
2478 #[should_panic(expected = "(width <= 600px)")]
2479 fn an_inclusive_range_upper_bound_is_stale() {
2480 let dir = frontend("bp-le-600");
2481 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2482 Breakpoints::in_frontend(&dir).run();
2483 }
2484
2485 #[test]
2486 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2487 let dir = frontend("bp-edges-ok");
2488 write(
2489 &dir,
2490 "css/styles.css",
2491 "@media (min-width: 600px) { }\n\
2492 @media (600px <= width) { }\n\
2493 @media (width >= 840px) { }\n",
2494 );
2495 Breakpoints::in_frontend(&dir).run();
2496 }
2497
2498 #[test]
2499 fn a_tuning_max_width_is_left_alone() {
2500 let dir = frontend("bp-tuning-max");
2503 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2504 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2505 }
2506
2507 #[test]
2508 fn a_call_in_script_is_not_a_feature() {
2509 let found = js_widths("const w = Math.max(width, 600);");
2510 assert!(found.is_empty(), "{found:?}");
2511 let found = js_widths("el.style.cssText = 'max-width: 320px';");
2512 assert!(found.is_empty(), "{found:?}");
2513 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2514 }
2515
2516 #[test]
2517 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2518 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2519 let stripped = blank(css, Lang::Css);
2520 assert!(!stripped.contains("768"), "{stripped:?}");
2521 assert!(stripped.contains("a { }"));
2522 assert_eq!(stripped.len(), css.len());
2523 }
2524
2525 #[test]
2526 fn a_comment_opener_inside_a_string_is_text() {
2527 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2529 let src = blank(css, Lang::Css);
2530 assert_eq!(media_conditions(&src).len(), 1);
2531 assert!(src.contains("768"));
2532
2533 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2534 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2535 }
2536
2537 #[test]
2538 fn a_regex_literal_does_not_open_a_comment() {
2539 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2542 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2543 }
2544
2545 #[test]
2546 fn a_division_is_not_a_regex_literal() {
2547 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2549 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2550 }
2551
2552 #[test]
2553 fn a_css_bad_string_ends_at_a_newline() {
2554 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2557 let src = blank(css, Lang::Css);
2558 assert_eq!(media_conditions(&src).len(), 1);
2559 assert!(src.contains("768"));
2560 }
2561
2562 #[test]
2563 fn an_escaped_quote_does_not_open_a_string() {
2564 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2565 let src = blank(css, Lang::Css);
2566 assert_eq!(media_conditions(&src).len(), 1);
2567 }
2568
2569 #[test]
2570 fn an_unquoted_url_does_not_open_a_comment() {
2571 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2572 let src = blank(css, Lang::Css);
2573 assert_eq!(media_conditions(&src).len(), 1);
2574 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2576 @media (max-width: 768px) { }\n";
2577 let src = blank(css, Lang::Css);
2578 assert!(!src.contains("gone"), "{src:?}");
2579 assert_eq!(media_conditions(&src).len(), 1);
2580 }
2581
2582 #[test]
2583 fn a_leading_dot_length_is_read() {
2584 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2586 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2587 }
2588
2589 #[test]
2590 fn an_at_media_keyword_reads_in_any_case() {
2591 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2592 assert_eq!(conditions.len(), 1);
2593 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2594 }
2595
2596 #[test]
2597 fn a_width_nested_in_a_function_is_read() {
2598 assert_eq!(
2599 media_widths("(max-width: calc(768px))"),
2600 vec![Width::Px(768)]
2601 );
2602 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2605 }
2606
2607 #[test]
2608 fn a_newline_does_not_reset_an_open_group() {
2609 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2610 }
2611
2612 #[test]
2613 fn custom_media_and_import_carry_media_queries() {
2614 let css = "@custom-media --narrow (max-width: 768px);\n\
2615 @import url(x.css) (max-width: 768px);\n";
2616 let conditions = media_conditions(css);
2617 assert_eq!(conditions.len(), 2);
2618 for (_, condition) in conditions {
2619 assert!(
2620 media_widths(condition).contains(&Width::Px(768)),
2621 "unread: {condition:?}"
2622 );
2623 }
2624 }
2625
2626 #[test]
2627 fn a_query_in_a_script_comment_is_prose() {
2628 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2629 let found = js_widths(&blank(js, Lang::Js));
2630 assert!(found.is_empty(), "{found:?}");
2631 }
2632
2633 #[test]
2634 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2635 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2636 let src = blank(css, Lang::Css);
2637 assert_eq!(media_conditions(&src).len(), 1);
2638 }
2639
2640 #[test]
2641 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2642 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2645 let src = blank(css, Lang::Css);
2646 assert_eq!(src.len(), css.len());
2647 let at = css.find("@layer").unwrap();
2648 assert_eq!(&src[at..at + 6], "@layer");
2649 let dropped = drop_layers(css, &["reset"]);
2650 assert!(!dropped.contains("padding"), "{dropped:?}");
2651 }
2652
2653 #[test]
2654 fn a_dropped_layer_keeps_its_important_rules() {
2655 let css =
2659 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2660 let dropped = drop_layers(css, &["reset"]);
2661 assert!(!dropped.contains("padding"), "{dropped:?}");
2662 assert!(dropped.contains(".card"), "{dropped:?}");
2663 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2664 }
2665
2666 #[test]
2667 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2668 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2669 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2670 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2671 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2672 }
2673
2674 #[test]
2675 fn blanking_keeps_lines_and_offsets() {
2676 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2677 let src = blank(css, Lang::Css);
2678 assert_eq!(src.len(), css.len());
2679 assert_eq!(src.lines().count(), css.lines().count());
2680 }
2681
2682 #[test]
2683 fn both_quote_styles_read() {
2684 let want = Density::Touch.media_condition();
2685 for q in ['\'', '"'] {
2686 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2687 let found = touch_density_literals(&src);
2688 assert_eq!(found.len(), 1);
2689 assert_eq!(found[0].1, want);
2690 }
2691 }
2692
2693 #[test]
2694 fn a_file_list_skips_the_sheets_named_generated() {
2695 let dir = frontend("vocab-files");
2696 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2697 write(&dir, "css/app.css", "body { color: red; }\n");
2698 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2699 Vocabulary::files(&files)
2700 .generated(&["layout.css"])
2701 .run(&Emit::default());
2702 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2703 .unwrap_err();
2704 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2705 assert!(msg.contains("box-shadow"), "got: {msg}");
2706 }
2707}