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 "drop-optional-",
1494 "drop-secondary-",
1495 ];
1496 let rung_of = |class: &str| -> Option<&'static str> {
1497 let name = class.strip_prefix(opts.class_prefix)?;
1498 LADDERS.iter().copied().find(|ladder| {
1499 name.strip_prefix(ladder)
1500 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1501 })
1502 };
1503 let mut unused: Vec<String> = generated
1504 .iter()
1505 .filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
1506 .cloned()
1507 .collect();
1508 for ladder in LADDERS {
1509 let rungs: Vec<&String> = generated
1510 .iter()
1511 .filter(|class| rung_of(class) == Some(ladder))
1512 .collect();
1513 if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
1514 unused.push(format!("{}{ladder}N", opts.class_prefix));
1515 }
1516 }
1517
1518 assert!(
1519 unused.len() <= high_water,
1520 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
1521 The vocabulary grew or the markup stopped using it:\n{}",
1522 unused.len(),
1523 generated.len(),
1524 high_water,
1525 unused
1526 .iter()
1527 .map(|c| format!(" .{c}"))
1528 .collect::<Vec<_>>()
1529 .join("\n")
1530 );
1531
1532 if unused.len() < high_water {
1533 println!(
1534 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
1535 lower the seal so it cannot grow back",
1536 unused.len(),
1537 high_water
1538 );
1539 }
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544 use super::*;
1545 use crate::test_support::scratch;
1546 use std::fmt::Write as _;
1547
1548 fn write(dir: &Path, name: &str, src: &str) {
1549 if let Some(parent) = dir.join(name).parent() {
1550 std::fs::create_dir_all(parent).unwrap();
1551 }
1552 std::fs::write(dir.join(name), src).unwrap();
1553 }
1554
1555 fn declaring() -> String {
1556 format!(
1557 "const {CONST_NAME} = '{}';\n",
1558 Density::Touch.media_condition()
1559 )
1560 }
1561
1562 #[test]
1563 fn the_crates_own_string_passes() {
1564 let dir = scratch("ok");
1565 write(&dir, "touch.js", &declaring());
1566 check_touch_density(&dir);
1567 }
1568
1569 #[test]
1570 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1571 fn a_drifted_literal_fails() {
1572 let dir = scratch("drift");
1573 write(&dir, "touch.js", &declaring());
1574 write(
1575 &dir,
1576 "haptics.js",
1577 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
1578 );
1579 check_touch_density(&dir);
1580 }
1581
1582 #[test]
1583 #[should_panic(expected = "device sniff")]
1584 fn the_sniff_cannot_come_back() {
1585 let dir = scratch("sniff");
1586 write(&dir, "touch.js", &declaring());
1587 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
1588 check_touch_density(&dir);
1589 }
1590
1591 #[test]
1592 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1593 fn a_frontend_that_states_nothing_fails() {
1594 let dir = scratch("empty");
1595 write(&dir, "app.js", "export const x = 1;\n");
1596 check_touch_density(&dir);
1597 }
1598
1599 #[test]
1600 fn a_use_site_is_not_a_declaration() {
1601 let src =
1605 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
1606 assert!(
1607 touch_density_literals(&src).is_empty(),
1608 "{:?}",
1609 touch_density_literals(&src)
1610 );
1611 }
1612
1613 #[test]
1614 fn whitespace_and_backtick_literals_read() {
1615 let want = Density::Touch.media_condition();
1616 for src in [
1617 format!("const {CONST_NAME}='{want}';\n"),
1618 format!("const {CONST_NAME} = `{want}`;\n"),
1619 format!("const {CONST_NAME} =\n '{want}';\n"),
1620 ] {
1621 let found = touch_density_literals(&src);
1622 assert_eq!(found.len(), 1, "src: {src:?}");
1623 assert_eq!(found[0].1, want, "src: {src:?}");
1624 }
1625 }
1626
1627 #[test]
1628 fn a_longer_identifier_is_not_the_const() {
1629 let want = Density::Touch.media_condition();
1631 for src in [
1632 format!("const OLD_{CONST_NAME} = '{want}';\n"),
1633 format!("const {CONST_NAME}_OLD = '{want}';\n"),
1634 ] {
1635 assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
1636 }
1637 }
1638
1639 #[test]
1640 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
1641 fn a_commented_out_declaration_is_not_a_declaration() {
1642 let dir = scratch("commented-decl");
1645 write(
1646 &dir,
1647 "touch.js",
1648 &format!("// {}\n", declaring().trim_end()),
1649 );
1650 check_touch_density(&dir);
1651 }
1652
1653 #[test]
1654 fn a_sniff_named_in_a_comment_is_not_a_use() {
1655 let dir = scratch("sniff-comment");
1657 write(&dir, "touch.js", &declaring());
1658 write(
1659 &dir,
1660 "note.js",
1661 "// never read ontouchstart or maxTouchPoints here\n",
1662 );
1663 check_touch_density(&dir);
1664 }
1665
1666 #[test]
1667 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
1668 fn a_wrong_literal_without_spaces_is_caught() {
1669 let dir = scratch("loose-drift");
1671 write(&dir, "touch.js", &declaring());
1672 write(
1673 &dir,
1674 "haptics.js",
1675 &format!("const {CONST_NAME}='(pointer: coarse)';\n"),
1676 );
1677 check_touch_density(&dir);
1678 }
1679
1680 #[test]
1681 fn nested_files_are_read() {
1682 let dir = scratch("nested");
1685 write(&dir, "touch.js", &declaring());
1686 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
1687 let files = js_files(&dir);
1688 assert_eq!(files.len(), 2);
1689 }
1690
1691 #[test]
1692 fn a_non_js_file_is_ignored() {
1693 let dir = scratch("nonjs");
1694 write(&dir, "touch.js", &declaring());
1695 write(&dir, "styles.css", "body { }\n");
1696 assert_eq!(js_files(&dir).len(), 1);
1697 }
1698
1699 fn frontend(name: &str) -> PathBuf {
1700 let dir = scratch(name);
1701 std::fs::create_dir_all(dir.join("css")).unwrap();
1702 std::fs::create_dir_all(dir.join("js")).unwrap();
1703 dir
1704 }
1705
1706 fn boundary() -> u16 {
1708 SizeClass::Medium.min_px()
1709 }
1710
1711 #[test]
1712 fn the_crates_own_boundaries_pass() {
1713 let dir = frontend("bp-ok");
1714 write(
1715 &dir,
1716 "css/styles.css",
1717 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1718 );
1719 Breakpoints::in_frontend(&dir).run();
1720 }
1721
1722 #[test]
1723 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1724 fn a_stale_css_width_fails() {
1725 let dir = frontend("bp-css");
1726 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
1727 Breakpoints::in_frontend(&dir).run();
1728 }
1729
1730 #[test]
1731 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1732 fn a_stale_js_width_fails() {
1733 let dir = frontend("bp-js");
1734 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
1735 Breakpoints::in_frontend(&dir).run();
1736 }
1737
1738 #[test]
1739 fn a_declared_tuning_width_passes() {
1740 let dir = frontend("bp-tuning");
1741 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
1742 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
1743 }
1744
1745 #[test]
1746 fn a_container_query_on_a_shell_boundary_passes() {
1747 let dir = frontend("bp-container-ok");
1751 write(
1752 &dir,
1753 "css/styles.css",
1754 &format!("@container screen (width < {}px) {{ }}\n", boundary()),
1755 );
1756 Breakpoints::in_frontend(&dir).run();
1757 }
1758
1759 #[test]
1760 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
1761 fn a_stale_container_width_fails() {
1762 let dir = frontend("bp-container-stale");
1763 write(
1764 &dir,
1765 "css/styles.css",
1766 "@container screen (width < 768px) { }\n",
1767 );
1768 Breakpoints::in_frontend(&dir).run();
1769 }
1770
1771 #[test]
1772 fn a_container_inline_size_condition_is_not_a_width_boundary() {
1773 let dir = frontend("bp-container-inline");
1776 write(
1777 &dir,
1778 "css/styles.css",
1779 "@container tab-strip (inline-size < 36rem) { }\n",
1780 );
1781 Breakpoints::in_frontend(&dir).run();
1782 }
1783
1784 #[test]
1785 fn a_width_in_a_comment_is_prose() {
1786 let dir = frontend("bp-comment");
1789 write(
1790 &dir,
1791 "css/styles.css",
1792 "/* was @media (max-width: 768px) until the size classes landed */\n",
1793 );
1794 Breakpoints::in_frontend(&dir).run();
1795 }
1796
1797 #[test]
1798 fn an_unparenthesized_width_is_not_a_breakpoint() {
1799 let dir = frontend("bp-inline");
1803 write(
1804 &dir,
1805 "js/style.js",
1806 "el.style.cssText = 'max-width: 320px; display: block';\n",
1807 );
1808 Breakpoints::in_frontend(&dir).run();
1809 }
1810
1811 #[test]
1812 fn nested_css_is_read() {
1813 let dir = frontend("bp-nested");
1816 write(
1817 &dir,
1818 "css/screens/detail.css",
1819 "@media (max-width: 768px) { }\n",
1820 );
1821 let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
1822 assert!(found.is_err(), "a nested stylesheet must be scanned");
1823 }
1824
1825 #[test]
1826 fn a_css_only_frontend_scans_without_a_js_directory() {
1827 let dir = scratch("bp-css-only");
1830 write(
1831 &dir,
1832 "css/styles.css",
1833 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
1834 );
1835 Breakpoints::in_frontend(&dir).run();
1836 }
1837
1838 #[test]
1839 fn a_named_list_is_checked() {
1840 let dir = frontend("bp-list");
1841 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1842 let listed = dir.join("css/style.css");
1843 let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
1844 let msg = err.downcast_ref::<String>().expect("String payload");
1845 assert!(msg.contains("style.css:1"), "got: {msg}");
1846 }
1847
1848 #[test]
1849 #[should_panic(expected = "read ")]
1850 fn a_listed_file_that_is_gone_fails() {
1851 let dir = frontend("bp-missing");
1854 Breakpoints::files(&[dir.join("css/never-written.css")]).run();
1855 }
1856
1857 #[test]
1858 fn a_listed_js_file_is_parsed_as_script() {
1859 let dir = frontend("bp-list-js");
1862 write(
1863 &dir,
1864 "js/style.js",
1865 "el.style.cssText = 'max-width: 320px';\n",
1866 );
1867 Breakpoints::files(&[dir.join("js/style.js")]).run();
1868 }
1869
1870 #[test]
1871 fn the_error_names_the_file_and_line() {
1872 let dir = frontend("bp-message");
1873 write(
1874 &dir,
1875 "css/styles.css",
1876 "body { }\n@media (max-width: 768px) { }\n",
1877 );
1878 let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
1879 let msg = err
1880 .downcast_ref::<String>()
1881 .expect("panic payload is a String");
1882 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1883 }
1884
1885 #[test]
1886 fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1887 let upstream = ".tile-set { gap: 4px; }\n";
1890 let dir = scratch("vocab-upstream");
1891 write(&dir, "css/quasi.css", upstream);
1892 write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1893 let err = std::panic::catch_unwind(|| {
1894 Vocabulary::in_frontend(&dir)
1895 .upstream(&[upstream])
1896 .run(&Emit::default());
1897 })
1898 .unwrap_err();
1899 let msg = err
1900 .downcast_ref::<String>()
1901 .expect("panic payload is a String");
1902 assert!(
1903 msg.contains("css/styles.css .tile-set { gap }"),
1904 "got: {msg}"
1905 );
1906 assert!(
1907 !msg.contains("css/quasi.css"),
1908 "the served copy clashed with itself: {msg}"
1909 );
1910
1911 Vocabulary::in_frontend(&dir).run(&Emit::default());
1914 }
1915
1916 #[test]
1917 fn a_rule_restating_a_generated_class_fails_and_names_it() {
1918 let dir = scratch("vocab-clash");
1919 write(
1922 &dir,
1923 "css/styles.css",
1924 "body { color: red; }\n.card { box-shadow: none; }\n",
1925 );
1926 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
1927 .unwrap_err();
1928 let msg = err
1929 .downcast_ref::<String>()
1930 .expect("panic payload is a String");
1931 assert!(msg.contains(".card"), "got: {msg}");
1932 assert!(msg.contains("box-shadow"), "got: {msg}");
1933 assert!(msg.contains("css/styles.css"), "got: {msg}");
1934 }
1935
1936 #[test]
1937 fn an_app_class_of_its_own_is_left_alone() {
1938 let dir = scratch("vocab-clean");
1939 write(
1940 &dir,
1941 "css/styles.css",
1942 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1943 );
1944 Vocabulary::in_frontend(&dir).run(&Emit::default());
1945 }
1946
1947 #[test]
1948 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1949 let dir = scratch("vocab-generated");
1950 let opts = Emit::default();
1951 write(&dir, "css/layout.css", &crate::stylesheet(&opts));
1952 Vocabulary::in_frontend(&dir)
1955 .generated(&["layout.css"])
1956 .run(&opts);
1957 }
1958
1959 #[test]
1960 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1961 let dir = scratch("vocab-prefix");
1962 let opts = Emit::new().class_prefix("mo-");
1963 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1966 Vocabulary::in_frontend(&dir).run(&opts);
1967
1968 let dir = scratch("vocab-prefix-clash");
1969 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1970 assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
1971 }
1972
1973 #[test]
1974 fn a_class_shared_without_a_shared_property_is_left_alone() {
1975 let dir = scratch("vocab-additive");
1976 write(
1979 &dir,
1980 "css/styles.css",
1981 ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1982 );
1983 Vocabulary::in_frontend(&dir).run(&Emit::default());
1984 }
1985
1986 #[test]
1987 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1988 let dir = scratch("vocab-allowed");
1989 write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
1990 Vocabulary::in_frontend(&dir)
1991 .allow(Allow::class("badge", "padding"))
1992 .run(&Emit::default());
1993
1994 let dir = scratch("vocab-allowed-stale");
1997 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1998 let err = std::panic::catch_unwind(|| {
1999 Vocabulary::in_frontend(&dir)
2000 .allow(Allow::class("badge", "padding"))
2001 .run(&Emit::default());
2002 })
2003 .unwrap_err();
2004 let msg = err
2005 .downcast_ref::<String>()
2006 .expect("panic payload is a String");
2007 assert!(msg.contains("no longer collides"), "got: {msg}");
2008 }
2009
2010 #[test]
2011 fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
2012 let dir = scratch("vocab-element");
2013 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2017 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2018 .unwrap_err();
2019 let msg = err
2020 .downcast_ref::<String>()
2021 .expect("panic payload is a String");
2022 assert!(msg.contains("select {"), "got: {msg}");
2023 assert!(msg.contains(".field"), "got: {msg}");
2024 assert!(msg.contains("box-shadow"), "got: {msg}");
2025 assert!(msg.contains("css/styles.css"), "got: {msg}");
2026 }
2027
2028 #[test]
2029 fn a_universal_reset_after_the_design_system_is_caught() {
2030 let dir = scratch("vocab-universal");
2034 write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
2035 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2036 .unwrap_err();
2037 let msg = err
2038 .downcast_ref::<String>()
2039 .expect("panic payload is a String");
2040 assert!(msg.contains("* {"), "got: {msg}");
2041 assert!(msg.contains("padding"), "got: {msg}");
2042 }
2043
2044 #[test]
2045 fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
2046 let dir = scratch("vocab-universal-beneath");
2047 write(
2048 &dir,
2049 "css/styles.css",
2050 "/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
2051 );
2052 Vocabulary::in_frontend(&dir)
2053 .beneath(&["reset"])
2054 .run(&Emit::default());
2055 }
2056
2057 #[test]
2058 fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
2059 let dir = scratch("vocab-important-beneath");
2060 write(
2065 &dir,
2066 "css/styles.css",
2067 "@layer reset {\n .field { box-shadow: none !important; }\n}\n",
2068 );
2069 let err = std::panic::catch_unwind(|| {
2070 Vocabulary::in_frontend(&dir)
2071 .beneath(&["reset"])
2072 .run(&Emit::default());
2073 })
2074 .unwrap_err();
2075 let msg = err.downcast_ref::<String>().expect("a String payload");
2076 assert!(msg.contains("box-shadow"), "got: {msg}");
2077 }
2078
2079 #[test]
2080 fn a_layer_not_named_beneath_is_still_read() {
2081 let dir = scratch("vocab-universal-other-layer");
2082 write(
2083 &dir,
2084 "css/styles.css",
2085 "@layer base {\n * { padding: 0; }\n}\n",
2086 );
2087 let err = std::panic::catch_unwind(|| {
2088 Vocabulary::in_frontend(&dir)
2089 .beneath(&["reset"])
2090 .run(&Emit::default());
2091 })
2092 .unwrap_err();
2093 let msg = err.downcast_ref::<String>().expect("a String payload");
2094 assert!(msg.contains("* {"), "got: {msg}");
2095 }
2096
2097 #[test]
2098 fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
2099 let dir = scratch("vocab-universal-box");
2100 write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
2101 Vocabulary::in_frontend(&dir).run(&Emit::default());
2102 }
2103
2104 #[test]
2105 fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
2106 let dir = scratch("vocab-element-handoff");
2107 write(
2112 &dir,
2113 "css/styles.css",
2114 "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2115 );
2116 Vocabulary::in_frontend(&dir).run(&Emit::default());
2117 }
2118
2119 #[test]
2120 fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
2121 let dir = scratch("vocab-element-spoken-for");
2122 write(
2127 &dir,
2128 "css/styles.css",
2129 "select { box-shadow: none; }\n.field { box-shadow: none; }\n\
2130 .field:disabled,\n.field[aria-disabled=\"true\"],\n\
2131 .field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
2132 );
2133 Vocabulary::in_frontend(&dir)
2134 .allow(Allow::class("field", "box-shadow"))
2135 .run(&Emit::default());
2136 }
2137
2138 #[test]
2139 fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
2140 let dir = scratch("vocab-element-weak-handoff");
2141 write(
2145 &dir,
2146 "css/styles.css",
2147 "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
2148 );
2149 let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
2150 .unwrap_err();
2151 let msg = err
2152 .downcast_ref::<String>()
2153 .expect("panic payload is a String");
2154 assert!(msg.contains(".field"), "got: {msg}");
2155
2156 let dir = scratch("vocab-element-strong-handoff");
2158 write(
2159 &dir,
2160 "css/styles.css",
2161 "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
2162 );
2163 Vocabulary::in_frontend(&dir).run(&Emit::default());
2164 }
2165
2166 #[test]
2167 fn a_scoped_rule_is_not_read_as_an_element_rule() {
2168 let dir = scratch("vocab-element-scoped");
2169 write(
2173 &dir,
2174 "css/styles.css",
2175 ".wizard select { box-shadow: none; }\n",
2176 );
2177 Vocabulary::in_frontend(&dir).run(&Emit::default());
2178 }
2179
2180 #[test]
2181 fn an_element_the_design_system_never_renders_onto_is_left_alone() {
2182 let dir = scratch("vocab-element-unpaired");
2183 write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
2186 Vocabulary::in_frontend(&dir).run(&Emit::default());
2187 }
2188
2189 #[test]
2190 fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
2191 let dir = scratch("vocab-cancel-tone");
2196 write(
2197 &dir,
2198 "css/styles.css",
2199 ".button { background: var(--surface-raised); }\n",
2200 );
2201 let err = std::panic::catch_unwind(|| {
2202 Vocabulary::in_frontend(&dir)
2203 .allow(Allow::class("button", "background"))
2204 .run(&Emit::default());
2205 })
2206 .unwrap_err();
2207 let msg = err
2208 .downcast_ref::<String>()
2209 .expect("panic payload is a String");
2210 assert!(
2211 msg.contains(".button { background } cancels"),
2212 "got: {msg}"
2213 );
2214 assert!(
2215 msg.contains(".button[data-tone=\"danger\"] { background }"),
2216 "the message names the variant it cancels: {msg}"
2217 );
2218 assert!(msg.contains("restate"), "and the remedy: {msg}");
2219 }
2220
2221 #[test]
2222 fn restating_every_cancelled_variant_on_its_own_arm_passes() {
2223 let opts = Emit::default();
2226 let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
2227 let mut css = String::from(".button { background: var(--surface-raised); }\n");
2228 for (arm, properties) in &variants["button"] {
2229 if properties.contains("background") {
2230 let loose = arm.replace('"', "'").replace(',', " , ");
2231 let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
2232 }
2233 }
2234 let dir = scratch("vocab-cancel-restated");
2235 write(&dir, "css/styles.css", &css);
2236 Vocabulary::in_frontend(&dir)
2237 .allow(Allow::class("button", "background"))
2238 .run(&opts);
2239 }
2240
2241 #[test]
2242 fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
2243 let dir = scratch("vocab-cancel-scoped");
2246 write(
2247 &dir,
2248 "css/styles.css",
2249 ".toolbar .button { color: var(--content); }\n\
2250 .button.quiet { color: var(--content); }\n",
2251 );
2252 Vocabulary::in_frontend(&dir)
2255 .allow(Allow::class("button", "color"))
2256 .run(&Emit::default());
2257 }
2258
2259 #[test]
2260 fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
2261 let dir = scratch("vocab-cancel-own-order");
2265 write(
2266 &dir,
2267 "css/quasi.css",
2268 "@layer reset, quasi, makeover;\n\
2269 @layer quasi {\n .button { cursor: pointer; }\n}\n",
2270 );
2271 Vocabulary::in_frontend(&dir)
2272 .allow(Allow::class("button", "cursor"))
2273 .run(&Emit::default());
2274
2275 let dir = scratch("vocab-cancel-own-order-above");
2277 write(
2278 &dir,
2279 "css/app.css",
2280 "@layer reset, makeover, app;\n\
2281 @layer app {\n .button { cursor: pointer; }\n}\n",
2282 );
2283 let err = std::panic::catch_unwind(|| {
2284 Vocabulary::in_frontend(&dir)
2285 .allow(Allow::class("button", "cursor"))
2286 .run(&Emit::default());
2287 })
2288 .unwrap_err();
2289 let msg = err
2290 .downcast_ref::<String>()
2291 .expect("panic payload is a String");
2292 assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
2293 }
2294
2295 #[test]
2296 fn a_handoff_on_the_bare_class_cancels_nothing() {
2297 let dir = scratch("vocab-cancel-handoff");
2298 write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
2299 Vocabulary::in_frontend(&dir).run(&Emit::default());
2300 }
2301
2302 #[test]
2303 fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
2304 let dir = scratch("vocab-element-allowed");
2305 write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
2306 Vocabulary::in_frontend(&dir)
2307 .allow(Allow::element("select", "field", "box-shadow"))
2308 .run(&Emit::default());
2309
2310 let dir = scratch("vocab-element-allowed-stale");
2313 write(&dir, "css/styles.css", "select { padding: 2px; }\n");
2314 let err = std::panic::catch_unwind(|| {
2315 Vocabulary::in_frontend(&dir)
2316 .allow(Allow::element("select", "field", "box-shadow"))
2317 .run(&Emit::default());
2318 })
2319 .unwrap_err();
2320 let msg = err
2321 .downcast_ref::<String>()
2322 .expect("panic payload is a String");
2323 assert!(msg.contains("no longer collides"), "got: {msg}");
2324 }
2325
2326 fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
2328 let mut rungs = 0;
2329 let mut ladders = 0;
2330 for ladder in [
2331 "min-",
2332 "short-",
2333 "stacks-",
2334 "drop-optional-",
2335 "drop-secondary-",
2336 ] {
2337 let here = names
2338 .iter()
2339 .filter(|c| {
2340 c.strip_prefix(ladder)
2341 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
2342 })
2343 .count();
2344 rungs += here;
2345 ladders += usize::from(here > 0);
2346 }
2347 (rungs, ladders)
2348 }
2349
2350 #[test]
2351 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
2352 let dir = scratch("vocab-seal");
2353 let opts = Emit::default();
2354 let names = crate::vocabulary::names(&opts);
2355 let (rungs, ladders) = ladders(&names);
2356 assert!(ladders >= 1, "the floor ladder is in the vocabulary");
2357 let dead = names.len() - rungs + ladders;
2360 write(&dir, "index.html", "<div></div>\n");
2361 let markup = [dir.join("index.html")];
2362
2363 check_vocabulary_use(&markup, &opts, dead);
2364 assert!(
2365 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
2366 "a vocabulary deader than the seal has to fail"
2367 );
2368 }
2369
2370 #[test]
2371 fn one_rung_of_the_floor_ladder_uses_the_ladder() {
2372 let opts = Emit::default();
2375 let names = crate::vocabulary::names(&opts);
2376 let (rungs, ladders) = ladders(&names);
2377 let dead = names.len() - rungs + ladders;
2378
2379 let dir = scratch("vocab-ladder");
2380 write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
2381 let one_rung = [dir.join("index.html")];
2382 check_vocabulary_use(&one_rung, &opts, dead - 2);
2384
2385 write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
2387 assert!(haystack_names("<div class=\"min-24\">", "min-24"));
2388 assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
2389 }
2390
2391 #[test]
2392 fn a_class_is_named_whole_on_both_sides() {
2393 assert!(haystack_names("class=\"row\"", "row"));
2394 assert!(!haystack_names("class=\"row-flex\"", "row"));
2395 assert!(!haystack_names("class=\"flex-row\"", "row"));
2396 assert!(haystack_names("el.classList.add('row')", "row"));
2397 }
2398
2399 #[test]
2400 fn range_syntax_and_units_are_read() {
2401 assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
2402 assert_eq!(
2403 media_widths(" (600px <= width < 840px)"),
2404 [Width::Px(600), Width::Px(840)]
2405 );
2406 assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
2407 assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
2408 assert_eq!(
2409 media_widths(" (max-width: 599.98px)"),
2410 [Width::Other("599.98px".into())]
2411 );
2412 assert_eq!(
2413 media_widths(" (width < 50vw)"),
2414 [Width::Other("50vw".into())]
2415 );
2416 let found = media_widths(" (min-height: 600px)");
2417 assert!(found.is_empty(), "{found:?}");
2418 let found = media_widths(" (max-device-width: 600px)");
2419 assert!(found.is_empty(), "{found:?}");
2420 let found = media_widths(" (hover: hover) and (pointer: fine)");
2421 assert!(found.is_empty(), "{found:?}");
2422 }
2423
2424 #[test]
2425 fn every_size_class_condition_passes_its_own_check() {
2426 let dir = frontend("bp-classes");
2427 let mut css = String::new();
2428 for class in SizeClass::all() {
2429 let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
2430 }
2431 write(&dir, "css/styles.css", &css);
2432 write(
2433 &dir,
2434 "js/shell.js",
2435 &format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
2436 );
2437 Breakpoints::in_frontend(&dir).run();
2438 }
2439
2440 #[test]
2441 #[should_panic(expected = "48em")]
2442 fn a_width_in_em_is_a_width_and_not_a_bare_number() {
2443 let dir = frontend("bp-em");
2444 write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
2445 Breakpoints::in_frontend(&dir).tuning(&[48]).run();
2446 }
2447
2448 #[test]
2449 #[should_panic(expected = "(width < 768px)")]
2450 fn a_stale_range_width_fails() {
2451 let dir = frontend("bp-range");
2452 write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
2453 Breakpoints::in_frontend(&dir).run();
2454 }
2455
2456 #[test]
2457 #[should_panic(expected = "599px")]
2458 fn the_old_inclusive_compact_bound_is_stale() {
2459 let dir = frontend("bp-599");
2460 write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
2461 Breakpoints::in_frontend(&dir).run();
2462 }
2463
2464 #[test]
2465 #[should_panic(expected = "(max-width: 600px)")]
2466 fn an_inclusive_bound_at_a_class_edge_is_stale() {
2467 let dir = frontend("bp-600-inclusive");
2471 write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
2472 Breakpoints::in_frontend(&dir).run();
2473 }
2474
2475 #[test]
2476 #[should_panic(expected = "(width <= 600px)")]
2477 fn an_inclusive_range_upper_bound_is_stale() {
2478 let dir = frontend("bp-le-600");
2479 write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
2480 Breakpoints::in_frontend(&dir).run();
2481 }
2482
2483 #[test]
2484 fn the_inclusive_lower_bound_and_edges_are_not_stale() {
2485 let dir = frontend("bp-edges-ok");
2486 write(
2487 &dir,
2488 "css/styles.css",
2489 "@media (min-width: 600px) { }\n\
2490 @media (600px <= width) { }\n\
2491 @media (width >= 840px) { }\n",
2492 );
2493 Breakpoints::in_frontend(&dir).run();
2494 }
2495
2496 #[test]
2497 fn a_tuning_max_width_is_left_alone() {
2498 let dir = frontend("bp-tuning-max");
2501 write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
2502 Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
2503 }
2504
2505 #[test]
2506 fn a_call_in_script_is_not_a_feature() {
2507 let found = js_widths("const w = Math.max(width, 600);");
2508 assert!(found.is_empty(), "{found:?}");
2509 let found = js_widths("el.style.cssText = 'max-width: 320px';");
2510 assert!(found.is_empty(), "{found:?}");
2511 assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
2512 }
2513
2514 #[test]
2515 fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
2516 let css = "/*/ @media (max-width: 768px) { } */ a { }";
2517 let stripped = blank(css, Lang::Css);
2518 assert!(!stripped.contains("768"), "{stripped:?}");
2519 assert!(stripped.contains("a { }"));
2520 assert_eq!(stripped.len(), css.len());
2521 }
2522
2523 #[test]
2524 fn a_comment_opener_inside_a_string_is_text() {
2525 let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
2527 let src = blank(css, Lang::Css);
2528 assert_eq!(media_conditions(&src).len(), 1);
2529 assert!(src.contains("768"));
2530
2531 let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
2532 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2533 }
2534
2535 #[test]
2536 fn a_regex_literal_does_not_open_a_comment() {
2537 let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
2540 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2541 }
2542
2543 #[test]
2544 fn a_division_is_not_a_regex_literal() {
2545 let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
2547 assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
2548 }
2549
2550 #[test]
2551 fn a_css_bad_string_ends_at_a_newline() {
2552 let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
2555 let src = blank(css, Lang::Css);
2556 assert_eq!(media_conditions(&src).len(), 1);
2557 assert!(src.contains("768"));
2558 }
2559
2560 #[test]
2561 fn an_escaped_quote_does_not_open_a_string() {
2562 let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
2563 let src = blank(css, Lang::Css);
2564 assert_eq!(media_conditions(&src).len(), 1);
2565 }
2566
2567 #[test]
2568 fn an_unquoted_url_does_not_open_a_comment() {
2569 let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
2570 let src = blank(css, Lang::Css);
2571 assert_eq!(media_conditions(&src).len(), 1);
2572 let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
2574 @media (max-width: 768px) { }\n";
2575 let src = blank(css, Lang::Css);
2576 assert!(!src.contains("gone"), "{src:?}");
2577 assert_eq!(media_conditions(&src).len(), 1);
2578 }
2579
2580 #[test]
2581 fn a_leading_dot_length_is_read() {
2582 assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
2584 assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
2585 }
2586
2587 #[test]
2588 fn an_at_media_keyword_reads_in_any_case() {
2589 let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
2590 assert_eq!(conditions.len(), 1);
2591 assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
2592 }
2593
2594 #[test]
2595 fn a_width_nested_in_a_function_is_read() {
2596 assert_eq!(
2597 media_widths("(max-width: calc(768px))"),
2598 vec![Width::Px(768)]
2599 );
2600 assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
2603 }
2604
2605 #[test]
2606 fn a_newline_does_not_reset_an_open_group() {
2607 assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
2608 }
2609
2610 #[test]
2611 fn custom_media_and_import_carry_media_queries() {
2612 let css = "@custom-media --narrow (max-width: 768px);\n\
2613 @import url(x.css) (max-width: 768px);\n";
2614 let conditions = media_conditions(css);
2615 assert_eq!(conditions.len(), 2);
2616 for (_, condition) in conditions {
2617 assert!(
2618 media_widths(condition).contains(&Width::Px(768)),
2619 "unread: {condition:?}"
2620 );
2621 }
2622 }
2623
2624 #[test]
2625 fn a_query_in_a_script_comment_is_prose() {
2626 let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
2627 let found = js_widths(&blank(js, Lang::Js));
2628 assert!(found.is_empty(), "{found:?}");
2629 }
2630
2631 #[test]
2632 fn a_brace_inside_a_css_string_does_not_end_a_condition() {
2633 let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
2634 let src = blank(css, Lang::Css);
2635 assert_eq!(media_conditions(&src).len(), 1);
2636 }
2637
2638 #[test]
2639 fn blanking_keeps_byte_offsets_through_multibyte_text() {
2640 let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
2643 let src = blank(css, Lang::Css);
2644 assert_eq!(src.len(), css.len());
2645 let at = css.find("@layer").unwrap();
2646 assert_eq!(&src[at..at + 6], "@layer");
2647 let dropped = drop_layers(css, &["reset"]);
2648 assert!(!dropped.contains("padding"), "{dropped:?}");
2649 }
2650
2651 #[test]
2652 fn a_dropped_layer_keeps_its_important_rules() {
2653 let css =
2657 "@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
2658 let dropped = drop_layers(css, &["reset"]);
2659 assert!(!dropped.contains("padding"), "{dropped:?}");
2660 assert!(dropped.contains(".card"), "{dropped:?}");
2661 assert!(dropped.contains("box-shadow"), "{dropped:?}");
2662 }
2663
2664 #[test]
2665 fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
2666 let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
2667 assert!(!drop_layers(dotted, &["reset"]).contains("color"));
2668 let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
2669 assert!(!drop_layers(upper, &["reset"]).contains("color"));
2670 }
2671
2672 #[test]
2673 fn blanking_keeps_lines_and_offsets() {
2674 let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
2675 let src = blank(css, Lang::Css);
2676 assert_eq!(src.len(), css.len());
2677 assert_eq!(src.lines().count(), css.lines().count());
2678 }
2679
2680 #[test]
2681 fn both_quote_styles_read() {
2682 let want = Density::Touch.media_condition();
2683 for q in ['\'', '"'] {
2684 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
2685 let found = touch_density_literals(&src);
2686 assert_eq!(found.len(), 1);
2687 assert_eq!(found[0].1, want);
2688 }
2689 }
2690
2691 #[test]
2692 fn a_file_list_skips_the_sheets_named_generated() {
2693 let dir = frontend("vocab-files");
2694 write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
2695 write(&dir, "css/app.css", "body { color: red; }\n");
2696 let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
2697 Vocabulary::files(&files)
2698 .generated(&["layout.css"])
2699 .run(&Emit::default());
2700 let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
2701 .unwrap_err();
2702 let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
2703 assert!(msg.contains("box-shadow"), "got: {msg}");
2704 }
2705}