use std::path::{Path, PathBuf};
use crate::Emit;
use makeover_geometry::{Density, SizeClass};
const CONST_NAME: &str = "TOUCH_DENSITY";
const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
pub fn check_touch_density(js_dir: impl AsRef<Path>) {
let js_dir = js_dir.as_ref();
let want = Density::Touch.media_condition();
let mut wrong: Vec<String> = Vec::new();
let mut found = 0usize;
let files = js_files(js_dir);
for path in &files {
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let src = blank(&raw, Lang::Js);
let name = path
.strip_prefix(js_dir)
.unwrap_or(path)
.display()
.to_string();
for (offset, literal) in touch_density_literals(&src) {
found += 1;
if literal != want {
wrong.push(format!(
" {name}:{} {CONST_NAME} = '{literal}'",
line_of(&src, offset)
));
}
}
for needle in SNIFFS {
if let Some(offset) = src.find(needle) {
wrong.push(format!(
" {name}:{} {needle} -- device sniff, not a density question",
line_of(&src, offset)
));
}
}
}
assert!(
found > 0,
"no {CONST_NAME} literal found under {}.\n\n\
A frontend that asks whether it is being touched states\n\
makeover_geometry::Density::Touch's media condition in a const of that\n\
name, and this check exists to keep every copy equal to it. If the\n\
const was renamed, rename it back rather than dropping the check; if\n\
this frontend genuinely asks no density question, drop the call.",
js_dir.display()
);
assert!(
wrong.is_empty(),
"hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
Density::Touch.media_condition() is: {want}\n\n\
Wrong:\n{}\n\n\
Fix the JS to state the crate's string. Never widen it to catch a\n\
device the query misses: density is what is pointing at the screen,\n\
and a laptop with a touchscreen and a mouse is a pointer device.",
wrong.join("\n")
);
for path in &files {
println!("cargo:rerun-if-changed={}", path.display());
}
println!("cargo:rerun-if-changed={}", js_dir.display());
}
fn js_files(dir: &Path) -> Vec<PathBuf> {
files_with_extension(dir, "js")
}
fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d)
.unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
.flatten()
{
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == ext) {
out.push(path);
}
}
}
out.sort();
out
}
fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut at = 0;
while let Some(i) = src[at..].find(CONST_NAME) {
let start = at + i;
at = start + CONST_NAME.len();
if start > 0 && is_ident_byte(src.as_bytes()[start - 1]) {
continue;
}
if src.as_bytes().get(at).copied().is_some_and(is_ident_byte) {
continue;
}
let rest = &src[at..];
let lead = rest.len() - rest.trim_start().len();
let rest = &rest[lead..];
if !rest.starts_with('=') {
continue;
}
let rest = &rest[1..];
let gap = rest.len() - rest.trim_start().len();
let rest = &rest[gap..];
let Some(quote @ ('\'' | '"' | '`')) = rest.chars().next() else {
continue;
};
let body = at + lead + 1 + gap + quote.len_utf8();
if let Some(j) = src[body..].find(quote) {
out.push((start, &src[body..body + j]));
at = body + j + 1;
}
}
out
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
}
fn line_of(src: &str, offset: usize) -> usize {
src[..offset].matches('\n').count() + 1
}
#[derive(Debug, Clone)]
enum Source {
Frontend(PathBuf),
Files(Vec<PathBuf>),
}
impl Source {
fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
Self::Files(paths.iter().map(|p| p.as_ref().to_path_buf()).collect())
}
}
#[derive(Debug, Clone)]
#[must_use = "a check does nothing until it is run"]
pub struct Breakpoints {
source: Source,
tuning: Vec<u16>,
}
impl Breakpoints {
pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
Self {
source: Source::Frontend(frontend.as_ref().to_path_buf()),
tuning: Vec::new(),
}
}
pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
Self {
source: Source::files(paths),
tuning: Vec::new(),
}
}
pub fn tuning(mut self, widths: &[u16]) -> Self {
self.tuning.extend_from_slice(widths);
self
}
pub fn run(&self) {
match &self.source {
Source::Frontend(frontend) => {
let mut files = files_with_extension(&frontend.join("css"), "css");
let js = frontend.join("js");
if js.is_dir() {
files.extend(js_files(&js));
}
check_paths(&files, &self.tuning, Some(frontend));
println!("cargo:rerun-if-changed={}", frontend.join("css").display());
println!("cargo:rerun-if-changed={}", js.display());
}
Source::Files(paths) => check_paths(paths, &self.tuning, None),
}
}
}
fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
let allowed = allowed_widths(tuning_widths);
let edges = size_class_widths();
let mut stale: Vec<String> = Vec::new();
for path in paths {
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let name = match root {
Some(root) => display_name(root, path),
None => path.display().to_string(),
};
if path.extension().is_some_and(|x| x == "js") {
for (offset, bound) in js_widths(&blank(&raw, Lang::Js)) {
if !bound.is_allowed(&allowed, &edges) {
stale.push(format!(
" {name}:{} ({})",
line_of(&raw, offset),
bound.width
));
}
}
continue;
}
let src = blank(&raw, Lang::Css);
for (offset, condition) in media_conditions(&src) {
for bound in media_bounds(condition) {
if !bound.is_allowed(&allowed, &edges) {
stale.push(format!(
" {name}:{} @media{condition} ({})",
line_of(&src, offset),
bound.width
));
}
}
}
}
assert!(
stale.is_empty(),
"hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
Allowed: {allowed:?}\n\
({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
Stale:\n{}\n\n\
Size classes are written in range syntax, `(width < 600px)`, because\n\
`(max-width: 599px)` leaves 599.5px in no class. If a size class moved,\n\
update these to match. If one of these is a new\n\
tuning width inside the wide shell rather than a shell boundary, add it\n\
to the caller's tuning list with a note saying what it tunes.\n\n\
Best of all, make the rule dimensional so it needs no threshold: a grid\n\
wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
clamp(). A threshold is for what appears and disappears.",
allowed
.iter()
.filter(|px| !tuning_widths.contains(px))
.collect::<Vec<_>>(),
stale.join("\n")
);
for path in paths {
println!("cargo:rerun-if-changed={}", path.display());
}
}
fn display_name(frontend: &Path, path: &Path) -> String {
path.strip_prefix(frontend)
.unwrap_or(path)
.display()
.to_string()
}
fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
let mut widths = size_class_widths();
widths.extend_from_slice(tuning_widths);
widths.sort_unstable();
widths.dedup();
widths
}
fn size_class_widths() -> Vec<u16> {
let mut widths: Vec<u16> = SizeClass::all()
.iter()
.flat_map(|c| media_widths(&c.media_condition()))
.map(|width| match width {
Width::Px(px) => px,
Width::Other(raw) => {
panic!("makeover-geometry wrote a size-class width this check cannot read: {raw}")
}
})
.collect();
widths.sort_unstable();
widths.dedup();
widths
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Width {
Px(u16),
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Bound {
width: Width,
direction: Direction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Edge,
Straddles,
}
impl Bound {
fn is_allowed(&self, allowed: &[u16], edges: &[u16]) -> bool {
match &self.width {
Width::Px(px) => {
allowed.contains(px)
&& !(edges.contains(px) && self.direction == Direction::Straddles)
}
Width::Other(_) => false,
}
}
}
impl std::fmt::Display for Width {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Px(px) => write!(f, "{px}px"),
Self::Other(raw) => f.write_str(raw),
}
}
}
fn media_widths(condition: &str) -> Vec<Width> {
media_bounds(condition)
.into_iter()
.map(|bound| bound.width)
.collect()
}
fn media_bounds(condition: &str) -> Vec<Bound> {
width_groups(condition)
.into_iter()
.flat_map(|(_, group)| lengths(group))
.collect()
}
fn width_groups(src: &str) -> Vec<(usize, &str)> {
let mut out: Vec<(usize, &str)> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
for (i, c) in src.char_indices() {
match c {
'(' => stack.push(i),
')' => {
if let Some(start) = stack.pop() {
let inner = &src[start + 1..i];
let encloses_read = out.iter().any(|&(inner_start, _)| inner_start > start);
if names_width(inner) && !encloses_read {
out.push((start, inner));
}
}
}
_ => {}
}
}
out
}
fn names_width(inner: &str) -> bool {
width_token_at(inner).is_some()
}
fn width_token_at(inner: &str) -> Option<usize> {
inner.match_indices("width").find_map(|(at, _)| {
let before = &inner[..at];
let prefix_ok = before.is_empty()
|| before.ends_with("min-")
|| before.ends_with("max-")
|| before
.chars()
.next_back()
.is_some_and(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_');
let after = inner[at + "width".len()..].chars().next();
(prefix_ok && after.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
.then_some(at)
})
}
fn lengths(group: &str) -> Vec<Bound> {
let bytes = group.as_bytes();
let width_at = width_token_at(group);
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let prev_ok = i == 0
|| !(bytes[i - 1].is_ascii_alphanumeric()
|| bytes[i - 1] == b'-'
|| bytes[i - 1] == b'.');
let starts_number = prev_ok
&& (bytes[i].is_ascii_digit()
|| (bytes[i] == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)));
if !starts_number {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
i += 1;
}
let number = &group[start..i];
let unit_start = i;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
let unit = &group[unit_start..i];
if unit.is_empty() {
continue;
}
let raw = &group[start..i];
let scale = match unit {
"px" => Some(1.0),
"em" | "rem" => Some(16.0),
_ => None,
};
let px = scale.and_then(|scale| number.parse::<f64>().ok().map(|n| n * scale));
let width = match px {
Some(px) if px.fract() == 0.0 && px >= 0.0 && px <= f64::from(u16::MAX) => {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Width::Px(px as u16)
}
_ => Width::Other(raw.to_string()),
};
out.push(Bound {
width,
direction: direction_of(group, start, i, width_at),
});
}
out
}
fn direction_of(group: &str, start: usize, end: usize, width_at: Option<usize>) -> Direction {
let Some(width_at) = width_at else {
return Direction::Edge;
};
let width_end = width_at + "width".len();
if start >= width_end {
let op = &group[width_end..start];
if op.contains("<=") {
Direction::Straddles
} else if op.contains(">=") || op.contains('<') {
Direction::Edge
} else if op.contains('>') {
Direction::Straddles
} else if group[..width_at].ends_with("min-") {
Direction::Edge
} else {
Direction::Straddles
}
} else {
let op = &group[end..width_at];
if op.contains("<=") || op.contains('>') {
Direction::Edge
} else {
Direction::Straddles
}
}
}
fn media_conditions(css: &str) -> Vec<(usize, &str)> {
let lower = css.to_ascii_lowercase();
let mut out = Vec::new();
let mut at = 0;
while let Some(rel) = lower[at..].find('@') {
let start = at + rel;
let kw_start = start + 1;
let kw_end = css[kw_start..]
.find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
.map_or(css.len(), |i| kw_start + i);
let terminator = match &lower[kw_start..kw_end] {
"media" | "container" => '{',
"custom-media" | "import" => ';',
_ => {
at = start + 1;
continue;
}
};
match css[kw_end..].find(terminator) {
Some(j) => {
out.push((start, &css[kw_end..kw_end + j]));
at = kw_end + j + 1;
}
None => break,
}
}
out
}
fn js_widths(src: &str) -> Vec<(usize, Bound)> {
width_groups(src)
.into_iter()
.filter(|(_, group)| is_feature(group))
.flat_map(|(offset, group)| lengths(group).into_iter().map(move |b| (offset, b)))
.collect()
}
fn is_feature(group: &str) -> bool {
let group = group.trim();
let colon = ["min-width", "max-width", "width"].iter().any(|name| {
group
.strip_prefix(name)
.is_some_and(|rest| rest.trim_start().starts_with(':'))
});
colon || group.contains(['<', '>'])
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Lang {
Css,
Js,
}
fn blank(src: &str, lang: Lang) -> String {
#[derive(Clone, Copy)]
enum State {
Code,
Block,
Line,
Quoted(char),
Regex,
RawUrl,
}
let blank_into = |out: &mut String, c: char| {
if c == '\n' {
out.push('\n');
} else {
out.extend(std::iter::repeat_n(' ', c.len_utf8()));
}
};
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut state = State::Code;
let mut prev: Option<char> = None;
let mut in_class = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match state {
State::Code => {
let at_url = lang == Lang::Css
&& (c == 'u' || c == 'U')
&& chars.get(i + 1).is_some_and(|x| *x == 'r' || *x == 'R')
&& chars.get(i + 2).is_some_and(|x| *x == 'l' || *x == 'L')
&& chars.get(i + 3) == Some(&'(')
&& !(i > 0
&& (chars[i - 1].is_ascii_alphanumeric()
|| chars[i - 1] == '_'
|| chars[i - 1] == '-'));
if at_url {
out.extend(chars[i..i + 4].iter());
i += 4;
while i < chars.len() && chars[i].is_ascii_whitespace() {
out.push(chars[i]);
i += 1;
}
match chars.get(i) {
Some(&q) if q == '"' || q == '\'' => {
out.push(q);
i += 1;
state = State::Quoted(q);
}
_ => state = State::RawUrl,
}
continue;
}
match c {
'/' if chars.get(i + 1) == Some(&'*') => {
out.push_str(" ");
i += 2;
state = State::Block;
}
'/' if lang == Lang::Js && chars.get(i + 1) == Some(&'/') => {
out.push_str(" ");
i += 2;
state = State::Line;
}
'/' if lang == Lang::Js && regex_can_follow(prev) => {
out.push('/');
i += 1;
in_class = false;
state = State::Regex;
}
'\\' => {
out.push('\\');
i += 1;
if let Some(&next) = chars.get(i) {
out.push(next);
i += 1;
}
}
'"' | '\'' | '`' if lang == Lang::Js || c != '`' => {
out.push(c);
i += 1;
state = State::Quoted(c);
}
_ => {
out.push(c);
if !c.is_whitespace() {
prev = Some(c);
}
i += 1;
}
}
}
State::Regex => {
if c == '\\' {
out.push(c);
i += 1;
if let Some(&next) = chars.get(i) {
out.push(next);
i += 1;
}
} else if c == '[' && !in_class {
out.push(c);
in_class = true;
i += 1;
} else if c == '/' && !in_class {
out.push(c);
i += 1;
prev = Some('/');
state = State::Code;
} else if c == '\n' {
out.push('\n');
i += 1;
state = State::Code;
} else {
if c == ']' {
in_class = false;
}
out.push(c);
i += 1;
}
}
State::RawUrl => {
if c == '\\' {
out.push(c);
i += 1;
if let Some(&next) = chars.get(i) {
out.push(next);
i += 1;
}
} else {
out.push(c);
i += 1;
if c == ')' || c == '\n' {
if c == ')' {
prev = Some(')');
}
state = State::Code;
}
}
}
State::Block => {
if c == '*' && chars.get(i + 1) == Some(&'/') {
out.push_str(" ");
i += 2;
state = State::Code;
} else {
blank_into(&mut out, c);
i += 1;
}
}
State::Line => {
blank_into(&mut out, c);
i += 1;
if c == '\n' {
state = State::Code;
}
}
State::Quoted(quote) => {
let keep = |out: &mut String, c: char| {
if lang == Lang::Js {
out.push(c);
} else {
blank_into(out, c);
}
};
if c == '\\' {
keep(&mut out, c);
i += 1;
if let Some(&next) = chars.get(i) {
keep(&mut out, next);
i += 1;
}
} else if c == quote {
out.push(c);
i += 1;
prev = Some(c);
state = State::Code;
} else if lang == Lang::Css && c == '\n' {
blank_into(&mut out, c);
i += 1;
state = State::Code;
} else {
keep(&mut out, c);
i += 1;
}
}
}
}
out
}
fn regex_can_follow(prev: Option<char>) -> bool {
match prev {
None => true,
Some(c) => {
!(c.is_alphanumeric()
|| c == '_'
|| c == '$'
|| c == ')'
|| c == ']'
|| c == '}'
|| c == '"'
|| c == '\''
|| c == '`')
}
}
}
fn drop_layers(css: &str, names: &[&str]) -> String {
if names.is_empty() {
return css.to_owned();
}
let scan = blank(css, Lang::Css);
let scan = scan.as_bytes();
let mut out = css.as_bytes().to_vec();
let mut at = 0;
while let Some(found) = find_at_layer(scan, at) {
let mut i = found + b"@layer".len();
while i < scan.len() && scan[i].is_ascii_whitespace() {
i += 1;
}
let start = i;
while i < scan.len()
&& (scan[i].is_ascii_alphanumeric() || matches!(scan[i], b'-' | b'_' | b'.'))
{
i += 1;
}
let name = &css[start..i];
while i < scan.len() && scan[i].is_ascii_whitespace() {
i += 1;
}
if i >= scan.len() || scan[i] != b'{' || !layer_is_beneath(name, names) {
at = found + 1;
continue;
}
let mut depth = 0usize;
let mut end = scan.len();
let mut balanced = false;
for (k, &byte) in scan.iter().enumerate().skip(i) {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
end = k + 1;
balanced = true;
break;
}
}
_ => {}
}
}
if balanced {
blank_dropped_layer(&mut out, scan, found, i, end - 1);
} else {
blank_span(&mut out, found, end);
}
at = end;
}
String::from_utf8(out).unwrap_or_else(|_| css.to_owned())
}
fn layer_is_beneath(name: &str, names: &[&str]) -> bool {
names.iter().any(|beneath| {
name == *beneath
|| name
.strip_prefix(beneath)
.is_some_and(|rest| rest.starts_with('.'))
})
}
fn find_at_layer(haystack: &[u8], from: usize) -> Option<usize> {
let needle = b"@layer";
let mut i = from;
while i + needle.len() <= haystack.len() {
if haystack[i..i + needle.len()].eq_ignore_ascii_case(needle) {
return Some(i);
}
i += 1;
}
None
}
fn blank_span(out: &mut [u8], start: usize, end: usize) {
for byte in &mut out[start..end] {
if *byte != b'\n' {
*byte = b' ';
}
}
}
fn blank_dropped_layer(out: &mut [u8], scan: &[u8], head: usize, open: usize, close: usize) {
blank_span(out, head, open + 1);
blank_dropped_content(out, scan, open + 1, close);
blank_span(out, close, close + 1);
}
fn blank_dropped_content(out: &mut [u8], scan: &[u8], start: usize, end: usize) {
let mut i = start;
while i < end {
let mut open = i;
while open < end && scan[open] != b'{' && scan[open] != b';' {
open += 1;
}
if open >= end {
blank_span(out, i, end);
break;
}
if scan[open] == b';' {
blank_span(out, i, open + 1);
i = open + 1;
continue;
}
let mut depth = 0usize;
let mut close = end;
let mut balanced = false;
for (k, &byte) in scan.iter().enumerate().take(end).skip(open) {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
close = k;
balanced = true;
break;
}
}
_ => {}
}
}
if !balanced {
blank_span(out, i, end);
break;
}
let is_at_rule = scan[i..open]
.iter()
.position(|b| !b.is_ascii_whitespace())
.is_some_and(|k| scan[i + k] == b'@');
if is_at_rule {
blank_dropped_content(out, scan, open + 1, close);
} else if !has_important(&scan[open + 1..close]) {
blank_span(out, i, close + 1);
}
i = close + 1;
}
}
fn has_important(bytes: &[u8]) -> bool {
let keyword = b"important";
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'!' {
let mut k = i + 1;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k + keyword.len() <= bytes.len()
&& bytes[k..k + keyword.len()].eq_ignore_ascii_case(keyword)
{
return true;
}
}
i += 1;
}
false
}
fn layers_ordered_below_makeover(css: &str) -> Vec<String> {
let scan = blank(css, Lang::Css);
let mut below = Vec::new();
for statement in scan.split(';') {
let Some(at) = statement.rfind("@layer") else {
continue;
};
let names: Vec<&str> = statement[at + "@layer".len()..]
.split(',')
.map(str::trim)
.collect();
if names.iter().any(|name| name.contains(['{', '}'])) {
continue;
}
if let Some(position) = names.iter().position(|name| *name == crate::CSS_LAYER) {
below.extend(names[..position].iter().map(|name| (*name).to_string()));
}
}
below
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Allow<'a> {
Class {
class: &'a str,
property: &'a str,
},
Element {
element: &'a str,
class: &'a str,
property: &'a str,
},
}
impl<'a> Allow<'a> {
pub const fn class(class: &'a str, property: &'a str) -> Self {
Self::Class { class, property }
}
pub const fn element(element: &'a str, class: &'a str, property: &'a str) -> Self {
Self::Element {
element,
class,
property,
}
}
}
#[derive(Debug, Clone)]
#[must_use = "a check does nothing until it is run"]
pub struct Vocabulary<'a> {
source: Source,
generated: Vec<&'a str>,
upstream: Vec<&'a str>,
allowed: Vec<Allow<'a>>,
beneath: Vec<&'a str>,
}
impl<'a> Vocabulary<'a> {
pub fn in_frontend(frontend: impl AsRef<Path>) -> Self {
Self::from(Source::Frontend(frontend.as_ref().to_path_buf()))
}
pub fn files<P: AsRef<Path>>(paths: &[P]) -> Self {
Self::from(Source::files(paths))
}
fn from(source: Source) -> Self {
Self {
source,
generated: Vec::new(),
upstream: Vec::new(),
allowed: Vec::new(),
beneath: Vec::new(),
}
}
pub fn generated(mut self, names: &[&'a str]) -> Self {
self.generated.extend_from_slice(names);
self
}
pub fn upstream(mut self, sheets: &[&'a str]) -> Self {
self.upstream.extend_from_slice(sheets);
self
}
pub fn beneath(mut self, layers: &[&'a str]) -> Self {
self.beneath.extend_from_slice(layers);
self
}
pub fn allow(mut self, allow: Allow<'a>) -> Self {
self.allowed.push(allow);
self
}
pub fn allowing(mut self, allows: impl IntoIterator<Item = Allow<'a>>) -> Self {
self.allowed.extend(allows);
self
}
pub fn run(&self, opts: &Emit) {
let classes: Vec<(&str, &str)> = self
.allowed
.iter()
.filter_map(|allow| match *allow {
Allow::Class { class, property } => Some((class, property)),
Allow::Element { .. } => None,
})
.collect();
let elements: Vec<(&str, &str, &str)> = self
.allowed
.iter()
.filter_map(|allow| match *allow {
Allow::Element {
element,
class,
property,
} => Some((element, class, property)),
Allow::Class { .. } => None,
})
.collect();
match &self.source {
Source::Frontend(frontend) => {
let css = frontend.join("css");
let files: Vec<PathBuf> = files_with_extension(&css, "css")
.into_iter()
.filter(|p| {
let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
!self.generated.contains(&name.as_str())
})
.collect();
check_vocabulary_paths(
&files,
opts,
Some(frontend),
&self.upstream,
&self.beneath,
&classes,
&elements,
);
println!("cargo::rerun-if-changed={}", css.display());
}
Source::Files(paths) => {
let files: Vec<PathBuf> = paths
.iter()
.filter(|p| !self.generated.iter().any(|name| p.ends_with(name)))
.cloned()
.collect();
check_vocabulary_paths(
&files,
opts,
None,
&self.upstream,
&self.beneath,
&classes,
&elements,
);
}
}
}
}
fn check_vocabulary_paths(
paths: &[PathBuf],
opts: &Emit,
root: Option<&Path>,
upstream: &[&str],
beneath: &[&str],
allowed: &[(&str, &str)],
allowed_elements: &[(&str, &str, &str)],
) {
let makeover = crate::vocabulary::declarations_by_class(&crate::stylesheet(opts));
let mut owned = makeover.clone();
for sheet in upstream {
for (class, properties) in crate::vocabulary::declarations_by_class(sheet) {
owned.entry(class).or_default().extend(properties);
}
}
let makeover_variants = crate::vocabulary::variants_by_class(&crate::stylesheet(opts));
let mut owned_variants = makeover_variants.clone();
for sheet in upstream {
for (class, arms) in crate::vocabulary::variants_by_class(sheet) {
let entry = owned_variants.entry(class).or_default();
for (arm, properties) in arms {
entry.entry(arm).or_default().extend(properties);
}
}
}
let mut cancels: Vec<String> = Vec::new();
let mut clashes: Vec<String> = Vec::new();
let mut seen: Vec<(String, String)> = Vec::new();
let mut element_clashes: Vec<String> = Vec::new();
let mut element_seen: Vec<(String, String, String)> = Vec::new();
for path in paths {
println!("cargo::rerun-if-changed={}", path.display());
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let name = match root {
Some(root) => display_name(root, path),
None => path.display().to_string(),
};
let (generated, variants) = if upstream.contains(&raw.as_str()) {
(&makeover, &makeover_variants)
} else {
(&owned, &owned_variants)
};
let raw = drop_layers(&raw, beneath);
let local = crate::vocabulary::declarations_by_class(&raw);
for (class, properties) in &local {
let Some(theirs) = generated.get(class) else {
continue;
};
for property in properties.intersection(theirs) {
seen.push((class.clone(), property.clone()));
if allowed.contains(&(class.as_str(), property.as_str())) {
continue;
}
clashes.push(format!(" {name} .{class} {{ {property} }}"));
}
}
let own_below = layers_ordered_below_makeover(&raw);
let own_below: Vec<&str> = own_below.iter().map(String::as_str).collect();
let above = drop_layers(&raw, &own_below);
let bare = crate::vocabulary::bare_declarations(&above);
let restated = crate::vocabulary::mentions_by_arm(&raw);
for (class, properties) in &bare {
let Some(arms) = variants.get(class) else {
continue;
};
for property in properties {
let lost: Vec<&str> = arms
.iter()
.filter(|(arm, theirs)| {
theirs.contains(property)
&& !restated
.get(arm.as_str())
.is_some_and(|mentioned| mentioned.contains(property))
})
.map(|(arm, _)| arm.as_str())
.collect();
if lost.is_empty() {
continue;
}
cancels.push(format!(
" {name} .{class} {{ {property} }} cancels {}",
lost.iter()
.map(|arm| format!("{arm} {{ {property} }}"))
.collect::<Vec<_>>()
.join(", ")
));
}
}
let mentioned = crate::vocabulary::mentions_by_class(&raw);
let by_element = crate::vocabulary::declarations_by_element(&raw);
for (element, properties) in &by_element {
for class in crate::vocabulary::classes_for_element(element, opts) {
let Some(theirs) = generated.get(&class) else {
continue;
};
for (property, rank) in properties {
if !theirs.contains(property) {
continue;
}
let spoken_for = mentioned
.get(&class)
.and_then(|properties| properties.get(property))
.is_some_and(|theirs| theirs >= rank);
if spoken_for {
continue;
}
element_seen.push((element.clone(), class.clone(), property.clone()));
if allowed_elements.contains(&(
element.as_str(),
class.as_str(),
property.as_str(),
)) {
continue;
}
element_clashes.push(format!(
" {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
));
}
}
}
}
assert!(
cancels.is_empty(),
"{} hand-written rule(s) on a bare generated class set a property the \
design system varies on that class by state, tone or context. The app \
sheet sits in a later cascade layer than @layer makeover, and layers \
resolve before specificity, so each of these beats every variant named \
beside it, everywhere:\n{}\n\nDelete the declaration, or restate each \
cancelled variant in the app's sheet on its own arm, after the bare rule \
(`color: revert-layer` hands the design system's value back). That is \
the remedy the cascade allows; an allowed-list entry does not cover \
this, because the reviewed pair is the class and the variants are \
what it silently takes.",
cancels.len(),
cancels.join("\n")
);
assert!(
clashes.is_empty(),
"{} hand-written declaration(s) take a property the generated stylesheet, \
or an upstream sheet named to this check, already sets on the same \
class. App CSS wins over @layer makeover, whether by a later layer or \
by being unlayered, so each of these wins over the design system \
silently:\n{}\n\nDelete the declaration, or, if \
it is a deliberate pairing on a different selector arm, add \
(class, property) to this check's allowed list and say why beside it. \
Count the consumers before deciding a divergence is worth keeping.",
clashes.len(),
clashes.join("\n")
);
assert!(
element_clashes.is_empty(),
"{} hand-written element rule(s) take a property the generated \
stylesheet sets on a class that element carries. App CSS wins over \
@layer makeover, whether by a later layer or by being unlayered, so a \
described component rendered on one of these elements loses the \
design system's version of that property silently, which can draw a \
destructive act like an ordinary one:\n{}\n\nHand the \
property back on the arms makeover paints \
(`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
rule so it stops reaching described markup, or add \
(element, class, property) to this check's allowed-elements list and \
say why beside it.",
element_clashes.len(),
element_clashes.join("\n")
);
let stale: Vec<&(&str, &str)> = allowed
.iter()
.filter(|(class, property)| {
!seen.contains(&((*class).to_string(), (*property).to_string()))
})
.collect();
assert!(
stale.is_empty(),
"the allowed list declares {stale:?}, which no longer collides with \
anything. Delete the entries: an exception nobody is using is where the \
next real collision lands and reads as company."
);
let stale: Vec<&(&str, &str, &str)> = allowed_elements
.iter()
.filter(|(element, class, property)| {
!element_seen.contains(&(
(*element).to_string(),
(*class).to_string(),
(*property).to_string(),
))
})
.collect();
assert!(
stale.is_empty(),
"the allowed-elements list declares {stale:?}, which no longer collides \
with anything. Delete the entries: an exception nobody is using is \
where the next real collision lands and reads as company."
);
}
fn haystack_names(haystack: &str, class: &str) -> bool {
let part = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
haystack.match_indices(class).any(|(at, found)| {
haystack[..at].chars().next_back().is_none_or(|c| !part(c))
&& haystack[at + found.len()..]
.chars()
.next()
.is_none_or(|c| !part(c))
})
}
pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
let generated = crate::vocabulary::names(opts);
let mut haystack = String::new();
for path in markup {
let path = path.as_ref();
println!("cargo::rerun-if-changed={}", path.display());
haystack.push_str(
&std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
);
haystack.push('\n');
}
const LADDERS: &[&str] = &["min-", "short-"];
let rung_of = |class: &str| -> Option<&'static str> {
let name = class.strip_prefix(opts.class_prefix)?;
LADDERS.iter().copied().find(|ladder| {
name.strip_prefix(ladder)
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
})
};
let mut unused: Vec<String> = generated
.iter()
.filter(|class| rung_of(class).is_none() && !haystack_names(&haystack, class))
.cloned()
.collect();
for ladder in LADDERS {
let rungs: Vec<&String> = generated
.iter()
.filter(|class| rung_of(class) == Some(ladder))
.collect();
if !rungs.is_empty() && !rungs.iter().any(|class| haystack_names(&haystack, class)) {
unused.push(format!("{}{ladder}N", opts.class_prefix));
}
}
assert!(
unused.len() <= high_water,
"{} of {} generated classes are emitted by no markup, above the recorded {}. \
The vocabulary grew or the markup stopped using it:\n{}",
unused.len(),
generated.len(),
high_water,
unused
.iter()
.map(|c| format!(" .{c}"))
.collect::<Vec<_>>()
.join("\n")
);
if unused.len() < high_water {
println!(
"cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
lower the seal so it cannot grow back",
unused.len(),
high_water
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch");
dir
}
fn write(dir: &Path, name: &str, src: &str) {
if let Some(parent) = dir.join(name).parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(dir.join(name), src).unwrap();
}
fn declaring() -> String {
format!(
"const {CONST_NAME} = '{}';\n",
Density::Touch.media_condition()
)
}
#[test]
fn the_crates_own_string_passes() {
let dir = scratch("ok");
write(&dir, "touch.js", &declaring());
check_touch_density(&dir);
}
#[test]
#[should_panic(expected = "disagrees with makeover_geometry::Density")]
fn a_drifted_literal_fails() {
let dir = scratch("drift");
write(&dir, "touch.js", &declaring());
write(
&dir,
"haptics.js",
&format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
);
check_touch_density(&dir);
}
#[test]
#[should_panic(expected = "device sniff")]
fn the_sniff_cannot_come_back() {
let dir = scratch("sniff");
write(&dir, "touch.js", &declaring());
write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
check_touch_density(&dir);
}
#[test]
#[should_panic(expected = "no TOUCH_DENSITY literal found")]
fn a_frontend_that_states_nothing_fails() {
let dir = scratch("empty");
write(&dir, "app.js", "export const x = 1;\n");
check_touch_density(&dir);
}
#[test]
fn a_use_site_is_not_a_declaration() {
let src =
format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
assert!(touch_density_literals(&src).is_empty());
}
#[test]
fn whitespace_and_backtick_literals_read() {
let want = Density::Touch.media_condition();
for src in [
format!("const {CONST_NAME}='{want}';\n"),
format!("const {CONST_NAME} = `{want}`;\n"),
format!("const {CONST_NAME} =\n '{want}';\n"),
] {
let found = touch_density_literals(&src);
assert_eq!(found.len(), 1, "src: {src:?}");
assert_eq!(found[0].1, want, "src: {src:?}");
}
}
#[test]
fn a_longer_identifier_is_not_the_const() {
let want = Density::Touch.media_condition();
for src in [
format!("const OLD_{CONST_NAME} = '{want}';\n"),
format!("const {CONST_NAME}_OLD = '{want}';\n"),
] {
assert!(touch_density_literals(&src).is_empty(), "src: {src:?}");
}
}
#[test]
#[should_panic(expected = "no TOUCH_DENSITY literal found")]
fn a_commented_out_declaration_is_not_a_declaration() {
let dir = scratch("commented-decl");
write(
&dir,
"touch.js",
&format!("// {}\n", declaring().trim_end()),
);
check_touch_density(&dir);
}
#[test]
fn a_sniff_named_in_a_comment_is_not_a_use() {
let dir = scratch("sniff-comment");
write(&dir, "touch.js", &declaring());
write(
&dir,
"note.js",
"// never read ontouchstart or maxTouchPoints here\n",
);
check_touch_density(&dir);
}
#[test]
#[should_panic(expected = "disagrees with makeover_geometry::Density")]
fn a_wrong_literal_without_spaces_is_caught() {
let dir = scratch("loose-drift");
write(&dir, "touch.js", &declaring());
write(
&dir,
"haptics.js",
&format!("const {CONST_NAME}='(pointer: coarse)';\n"),
);
check_touch_density(&dir);
}
#[test]
fn nested_files_are_read() {
let dir = scratch("nested");
write(&dir, "touch.js", &declaring());
write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
let files = js_files(&dir);
assert_eq!(files.len(), 2);
}
#[test]
fn a_non_js_file_is_ignored() {
let dir = scratch("nonjs");
write(&dir, "touch.js", &declaring());
write(&dir, "styles.css", "body { }\n");
assert_eq!(js_files(&dir).len(), 1);
}
fn frontend(name: &str) -> PathBuf {
let dir = scratch(name);
std::fs::create_dir_all(dir.join("css")).unwrap();
std::fs::create_dir_all(dir.join("js")).unwrap();
dir
}
fn boundary() -> u16 {
SizeClass::Medium.min_px()
}
#[test]
fn the_crates_own_boundaries_pass() {
let dir = frontend("bp-ok");
write(
&dir,
"css/styles.css",
&format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
fn a_stale_css_width_fails() {
let dir = frontend("bp-css");
write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
fn a_stale_js_width_fails() {
let dir = frontend("bp-js");
write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn a_declared_tuning_width_passes() {
let dir = frontend("bp-tuning");
write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
}
#[test]
fn a_container_query_on_a_shell_boundary_passes() {
let dir = frontend("bp-container-ok");
write(
&dir,
"css/styles.css",
&format!("@container screen (width < {}px) {{ }}\n", boundary()),
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
fn a_stale_container_width_fails() {
let dir = frontend("bp-container-stale");
write(
&dir,
"css/styles.css",
"@container screen (width < 768px) { }\n",
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn a_container_inline_size_condition_is_not_a_width_boundary() {
let dir = frontend("bp-container-inline");
write(
&dir,
"css/styles.css",
"@container tab-strip (inline-size < 36rem) { }\n",
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn a_width_in_a_comment_is_prose() {
let dir = frontend("bp-comment");
write(
&dir,
"css/styles.css",
"/* was @media (max-width: 768px) until the size classes landed */\n",
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn an_unparenthesized_width_is_not_a_breakpoint() {
let dir = frontend("bp-inline");
write(
&dir,
"js/style.js",
"el.style.cssText = 'max-width: 320px; display: block';\n",
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn nested_css_is_read() {
let dir = frontend("bp-nested");
write(
&dir,
"css/screens/detail.css",
"@media (max-width: 768px) { }\n",
);
let found = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run());
assert!(found.is_err(), "a nested stylesheet must be scanned");
}
#[test]
fn a_css_only_frontend_scans_without_a_js_directory() {
let dir = scratch("bp-css-only");
write(
&dir,
"css/styles.css",
&format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn a_named_list_is_checked() {
let dir = frontend("bp-list");
write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
let listed = dir.join("css/style.css");
let err = std::panic::catch_unwind(|| Breakpoints::files(&[&listed]).run()).unwrap_err();
let msg = err.downcast_ref::<String>().expect("String payload");
assert!(msg.contains("style.css:1"), "got: {msg}");
}
#[test]
#[should_panic(expected = "read ")]
fn a_listed_file_that_is_gone_fails() {
let dir = frontend("bp-missing");
Breakpoints::files(&[dir.join("css/never-written.css")]).run();
}
#[test]
fn a_listed_js_file_is_parsed_as_script() {
let dir = frontend("bp-list-js");
write(
&dir,
"js/style.js",
"el.style.cssText = 'max-width: 320px';\n",
);
Breakpoints::files(&[dir.join("js/style.js")]).run();
}
#[test]
fn the_error_names_the_file_and_line() {
let dir = frontend("bp-message");
write(
&dir,
"css/styles.css",
"body { }\n@media (max-width: 768px) { }\n",
);
let err = std::panic::catch_unwind(|| Breakpoints::in_frontend(&dir).run()).unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("css/styles.css:2"), "got: {msg}");
}
#[test]
fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
let upstream = ".tile-set { gap: 4px; }\n";
let dir = scratch("vocab-upstream");
write(&dir, "css/quasi.css", upstream);
write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.upstream(&[upstream])
.run(&Emit::default());
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(
msg.contains("css/styles.css .tile-set { gap }"),
"got: {msg}"
);
assert!(
!msg.contains("css/quasi.css"),
"the served copy clashed with itself: {msg}"
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_rule_restating_a_generated_class_fails_and_names_it() {
let dir = scratch("vocab-clash");
write(
&dir,
"css/styles.css",
"body { color: red; }\n.card { box-shadow: none; }\n",
);
let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains(".card"), "got: {msg}");
assert!(msg.contains("box-shadow"), "got: {msg}");
assert!(msg.contains("css/styles.css"), "got: {msg}");
}
#[test]
fn an_app_class_of_its_own_is_left_alone() {
let dir = scratch("vocab-clean");
write(
&dir,
"css/styles.css",
".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
let dir = scratch("vocab-generated");
let opts = Emit::default();
write(&dir, "css/layout.css", &crate::stylesheet(&opts));
Vocabulary::in_frontend(&dir)
.generated(&["layout.css"])
.run(&opts);
}
#[test]
fn a_prefixed_app_is_checked_against_its_own_prefix() {
let dir = scratch("vocab-prefix");
let opts = Emit::new().class_prefix("mo-");
write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
Vocabulary::in_frontend(&dir).run(&opts);
let dir = scratch("vocab-prefix-clash");
write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
assert!(std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&opts)).is_err());
}
#[test]
fn a_class_shared_without_a_shared_property_is_left_alone() {
let dir = scratch("vocab-additive");
write(
&dir,
"css/styles.css",
".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
let dir = scratch("vocab-allowed");
write(&dir, "css/styles.css", ".badge { padding: 0; }\n");
Vocabulary::in_frontend(&dir)
.allow(Allow::class("badge", "padding"))
.run(&Emit::default());
let dir = scratch("vocab-allowed-stale");
write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.allow(Allow::class("badge", "padding"))
.run(&Emit::default());
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("no longer collides"), "got: {msg}");
}
#[test]
fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
let dir = scratch("vocab-element");
write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("select {"), "got: {msg}");
assert!(msg.contains(".field"), "got: {msg}");
assert!(msg.contains("box-shadow"), "got: {msg}");
assert!(msg.contains("css/styles.css"), "got: {msg}");
}
#[test]
fn a_universal_reset_after_the_design_system_is_caught() {
let dir = scratch("vocab-universal");
write(&dir, "css/styles.css", "* { margin: 0; padding: 0; }\n");
let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("* {"), "got: {msg}");
assert!(msg.contains("padding"), "got: {msg}");
}
#[test]
fn a_universal_reset_in_a_layer_beneath_makeover_is_not_read() {
let dir = scratch("vocab-universal-beneath");
write(
&dir,
"css/styles.css",
"/* { */ @layer reset {\n * { margin: 0; padding: 0; }\n}\n.x { content: \"}\"; }\n",
);
Vocabulary::in_frontend(&dir)
.beneath(&["reset"])
.run(&Emit::default());
}
#[test]
fn an_important_rule_in_a_layer_beneath_makeover_is_still_read() {
let dir = scratch("vocab-important-beneath");
write(
&dir,
"css/styles.css",
"@layer reset {\n .field { box-shadow: none !important; }\n}\n",
);
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.beneath(&["reset"])
.run(&Emit::default());
})
.unwrap_err();
let msg = err.downcast_ref::<String>().expect("a String payload");
assert!(msg.contains("box-shadow"), "got: {msg}");
}
#[test]
fn a_layer_not_named_beneath_is_still_read() {
let dir = scratch("vocab-universal-other-layer");
write(
&dir,
"css/styles.css",
"@layer base {\n * { padding: 0; }\n}\n",
);
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.beneath(&["reset"])
.run(&Emit::default());
})
.unwrap_err();
let msg = err.downcast_ref::<String>().expect("a String payload");
assert!(msg.contains("* {"), "got: {msg}");
}
#[test]
fn a_universal_rule_touching_nothing_generated_is_not_a_clash() {
let dir = scratch("vocab-universal-box");
write(&dir, "css/styles.css", "* { box-sizing: border-box; }\n");
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
let dir = scratch("vocab-element-handoff");
write(
&dir,
"css/styles.css",
"select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
let dir = scratch("vocab-element-spoken-for");
write(
&dir,
"css/styles.css",
"select { box-shadow: none; }\n.field { box-shadow: none; }\n\
.field:disabled,\n.field[aria-disabled=\"true\"],\n\
.field[aria-invalid=\"true\"] {\n box-shadow: revert-layer;\n}\n",
);
Vocabulary::in_frontend(&dir)
.allow(Allow::class("field", "box-shadow"))
.run(&Emit::default());
}
#[test]
fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
let dir = scratch("vocab-element-weak-handoff");
write(
&dir,
"css/styles.css",
"select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
);
let err = std::panic::catch_unwind(|| Vocabulary::in_frontend(&dir).run(&Emit::default()))
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains(".field"), "got: {msg}");
let dir = scratch("vocab-element-strong-handoff");
write(
&dir,
"css/styles.css",
"select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_scoped_rule_is_not_read_as_an_element_rule() {
let dir = scratch("vocab-element-scoped");
write(
&dir,
"css/styles.css",
".wizard select { box-shadow: none; }\n",
);
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn an_element_the_design_system_never_renders_onto_is_left_alone() {
let dir = scratch("vocab-element-unpaired");
write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_bare_rule_that_cancels_a_tone_fails_even_when_the_pair_is_reviewed() {
let dir = scratch("vocab-cancel-tone");
write(
&dir,
"css/styles.css",
".button { background: var(--surface-raised); }\n",
);
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.allow(Allow::class("button", "background"))
.run(&Emit::default());
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(
msg.contains(".button { background } cancels"),
"got: {msg}"
);
assert!(
msg.contains(".button[data-tone=\"danger\"] { background }"),
"the message names the variant it cancels: {msg}"
);
assert!(msg.contains("restate"), "and the remedy: {msg}");
}
#[test]
fn restating_every_cancelled_variant_on_its_own_arm_passes() {
let opts = Emit::default();
let variants = crate::vocabulary::variants_by_class(&crate::stylesheet(&opts));
let mut css = String::from(".button { background: var(--surface-raised); }\n");
for (arm, properties) in &variants["button"] {
if properties.contains("background") {
let loose = arm.replace('"', "'").replace(',', " , ");
let _ = writeln!(css, "{loose} {{ background: revert-layer; }}");
}
}
let dir = scratch("vocab-cancel-restated");
write(&dir, "css/styles.css", &css);
Vocabulary::in_frontend(&dir)
.allow(Allow::class("button", "background"))
.run(&opts);
}
#[test]
fn a_scoped_or_qualified_rule_cancels_nothing_this_can_prove() {
let dir = scratch("vocab-cancel-scoped");
write(
&dir,
"css/styles.css",
".toolbar .button { color: var(--content); }\n\
.button.quiet { color: var(--content); }\n",
);
Vocabulary::in_frontend(&dir)
.allow(Allow::class("button", "color"))
.run(&Emit::default());
}
#[test]
fn a_layer_the_sheet_orders_below_makeover_cancels_nothing() {
let dir = scratch("vocab-cancel-own-order");
write(
&dir,
"css/quasi.css",
"@layer reset, quasi, makeover;\n\
@layer quasi {\n .button { cursor: pointer; }\n}\n",
);
Vocabulary::in_frontend(&dir)
.allow(Allow::class("button", "cursor"))
.run(&Emit::default());
let dir = scratch("vocab-cancel-own-order-above");
write(
&dir,
"css/app.css",
"@layer reset, makeover, app;\n\
@layer app {\n .button { cursor: pointer; }\n}\n",
);
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.allow(Allow::class("button", "cursor"))
.run(&Emit::default());
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains(".button { cursor } cancels"), "got: {msg}");
}
#[test]
fn a_handoff_on_the_bare_class_cancels_nothing() {
let dir = scratch("vocab-cancel-handoff");
write(&dir, "css/styles.css", ".button { color: revert-layer; }\n");
Vocabulary::in_frontend(&dir).run(&Emit::default());
}
#[test]
fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
let dir = scratch("vocab-element-allowed");
write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
Vocabulary::in_frontend(&dir)
.allow(Allow::element("select", "field", "box-shadow"))
.run(&Emit::default());
let dir = scratch("vocab-element-allowed-stale");
write(&dir, "css/styles.css", "select { padding: 2px; }\n");
let err = std::panic::catch_unwind(|| {
Vocabulary::in_frontend(&dir)
.allow(Allow::element("select", "field", "box-shadow"))
.run(&Emit::default());
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("no longer collides"), "got: {msg}");
}
fn ladders(names: &std::collections::BTreeSet<String>) -> (usize, usize) {
let mut rungs = 0;
let mut ladders = 0;
for ladder in ["min-", "short-"] {
let here = names
.iter()
.filter(|c| {
c.strip_prefix(ladder)
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
})
.count();
rungs += here;
ladders += usize::from(here > 0);
}
(rungs, ladders)
}
#[test]
fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
let dir = scratch("vocab-seal");
let opts = Emit::default();
let names = crate::vocabulary::names(&opts);
let (rungs, ladders) = ladders(&names);
assert!(ladders >= 1, "the floor ladder is in the vocabulary");
let dead = names.len() - rungs + ladders;
write(&dir, "index.html", "<div></div>\n");
let markup = [dir.join("index.html")];
check_vocabulary_use(&markup, &opts, dead);
assert!(
std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
"a vocabulary deader than the seal has to fail"
);
}
#[test]
fn one_rung_of_the_floor_ladder_uses_the_ladder() {
let opts = Emit::default();
let names = crate::vocabulary::names(&opts);
let (rungs, ladders) = ladders(&names);
let dead = names.len() - rungs + ladders;
let dir = scratch("vocab-ladder");
write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
let one_rung = [dir.join("index.html")];
check_vocabulary_use(&one_rung, &opts, dead - 2);
write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
assert!(haystack_names("<div class=\"min-24\">", "min-24"));
assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
}
#[test]
fn a_class_is_named_whole_on_both_sides() {
assert!(haystack_names("class=\"row\"", "row"));
assert!(!haystack_names("class=\"row-flex\"", "row"));
assert!(!haystack_names("class=\"flex-row\"", "row"));
assert!(haystack_names("el.classList.add('row')", "row"));
}
#[test]
fn range_syntax_and_units_are_read() {
assert_eq!(media_widths("(width < 600px)"), [Width::Px(600)]);
assert_eq!(
media_widths(" (600px <= width < 840px)"),
[Width::Px(600), Width::Px(840)]
);
assert_eq!(media_widths(" (max-width: 48em)"), [Width::Px(768)]);
assert_eq!(media_widths(" (min-width: 37.5rem)"), [Width::Px(600)]);
assert_eq!(
media_widths(" (max-width: 599.98px)"),
[Width::Other("599.98px".into())]
);
assert_eq!(
media_widths(" (width < 50vw)"),
[Width::Other("50vw".into())]
);
assert!(media_widths(" (min-height: 600px)").is_empty());
assert!(media_widths(" (max-device-width: 600px)").is_empty());
assert!(media_widths(" (hover: hover) and (pointer: fine)").is_empty());
}
#[test]
fn every_size_class_condition_passes_its_own_check() {
let dir = frontend("bp-classes");
let mut css = String::new();
for class in SizeClass::all() {
let _ = writeln!(css, "@media {} {{ body {{ }} }}", class.media_condition());
}
write(&dir, "css/styles.css", &css);
write(
&dir,
"js/shell.js",
&format!("matchMedia('{}');\n", SizeClass::Compact.media_condition()),
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "48em")]
fn a_width_in_em_is_a_width_and_not_a_bare_number() {
let dir = frontend("bp-em");
write(&dir, "css/styles.css", "@media (max-width: 48em) { }\n");
Breakpoints::in_frontend(&dir).tuning(&[48]).run();
}
#[test]
#[should_panic(expected = "(width < 768px)")]
fn a_stale_range_width_fails() {
let dir = frontend("bp-range");
write(&dir, "css/styles.css", "@media (width < 768px) { }\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "599px")]
fn the_old_inclusive_compact_bound_is_stale() {
let dir = frontend("bp-599");
write(&dir, "css/styles.css", "@media (max-width: 599px) { }\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "(max-width: 600px)")]
fn an_inclusive_bound_at_a_class_edge_is_stale() {
let dir = frontend("bp-600-inclusive");
write(&dir, "css/styles.css", "@media (max-width: 600px) { }\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
#[should_panic(expected = "(width <= 600px)")]
fn an_inclusive_range_upper_bound_is_stale() {
let dir = frontend("bp-le-600");
write(&dir, "css/styles.css", "@media (width <= 600px) { }\n");
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn the_inclusive_lower_bound_and_edges_are_not_stale() {
let dir = frontend("bp-edges-ok");
write(
&dir,
"css/styles.css",
"@media (min-width: 600px) { }\n\
@media (600px <= width) { }\n\
@media (width >= 840px) { }\n",
);
Breakpoints::in_frontend(&dir).run();
}
#[test]
fn a_tuning_max_width_is_left_alone() {
let dir = frontend("bp-tuning-max");
write(&dir, "css/styles.css", "@media (max-width: 1400px) { }\n");
Breakpoints::in_frontend(&dir).tuning(&[1400]).run();
}
#[test]
fn a_call_in_script_is_not_a_feature() {
assert!(js_widths("const w = Math.max(width, 600);").is_empty());
assert!(js_widths("el.style.cssText = 'max-width: 320px';").is_empty());
assert_eq!(js_widths("matchMedia('(width < 600px)')").len(), 1);
}
#[test]
fn a_slash_star_slash_opens_a_comment_rather_than_closing_one() {
let css = "/*/ @media (max-width: 768px) { } */ a { }";
let stripped = blank(css, Lang::Css);
assert!(!stripped.contains("768"), "{stripped:?}");
assert!(stripped.contains("a { }"));
assert_eq!(stripped.len(), css.len());
}
#[test]
fn a_comment_opener_inside_a_string_is_text() {
let css = "a::after { content: \"/*\"; }\n@media (max-width: 768px) { }\n";
let src = blank(css, Lang::Css);
assert_eq!(media_conditions(&src).len(), 1);
assert!(src.contains("768"));
let js = "const glob = '/*';\nmatchMedia('(width < 600px)');\n";
assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
}
#[test]
fn a_regex_literal_does_not_open_a_comment() {
let js = "s.replace(/\\/*$/, '');\nmatchMedia('(width < 600px)');\n";
assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
}
#[test]
fn a_division_is_not_a_regex_literal() {
let js = "const half = a / b;\nmatchMedia('(width < 600px)');\n";
assert_eq!(js_widths(&blank(js, Lang::Js)).len(), 1);
}
#[test]
fn a_css_bad_string_ends_at_a_newline() {
let css = "p::before { content: 'oops\n}\n@media (max-width: 768px) { }\n";
let src = blank(css, Lang::Css);
assert_eq!(media_conditions(&src).len(), 1);
assert!(src.contains("768"));
}
#[test]
fn an_escaped_quote_does_not_open_a_string() {
let css = ".a\\'b { color: red; }\n@media (max-width: 768px) { }\n";
let src = blank(css, Lang::Css);
assert_eq!(media_conditions(&src).len(), 1);
}
#[test]
fn an_unquoted_url_does_not_open_a_comment() {
let css = "a { background: url(/img/*.png); }\n@media (max-width: 768px) { }\n";
let src = blank(css, Lang::Css);
assert_eq!(media_conditions(&src).len(), 1);
let css = "a { background: url(\"/x/*.png\"); /* gone */ }\n\
@media (max-width: 768px) { }\n";
let src = blank(css, Lang::Css);
assert!(!src.contains("gone"), "{src:?}");
assert_eq!(media_conditions(&src).len(), 1);
}
#[test]
fn a_leading_dot_length_is_read() {
assert_eq!(media_widths("(max-width: .5em)"), vec![Width::Px(8)]);
assert_eq!(media_widths("(max-width: 37.5em)"), vec![Width::Px(600)]);
}
#[test]
fn an_at_media_keyword_reads_in_any_case() {
let conditions = media_conditions("@MEDIA (max-width: 768px) { body { } }");
assert_eq!(conditions.len(), 1);
assert_eq!(media_widths(conditions[0].1), vec![Width::Px(768)]);
}
#[test]
fn a_width_nested_in_a_function_is_read() {
assert_eq!(
media_widths("(max-width: calc(768px))"),
vec![Width::Px(768)]
);
assert!(media_widths("(max-width: calc(48em + 1px))").contains(&Width::Px(768)));
}
#[test]
fn a_newline_does_not_reset_an_open_group() {
assert_eq!(media_widths("(max-width:\n 768px)"), vec![Width::Px(768)]);
}
#[test]
fn custom_media_and_import_carry_media_queries() {
let css = "@custom-media --narrow (max-width: 768px);\n\
@import url(x.css) (max-width: 768px);\n";
let conditions = media_conditions(css);
assert_eq!(conditions.len(), 2);
for (_, condition) in conditions {
assert!(
media_widths(condition).contains(&Width::Px(768)),
"unread: {condition:?}"
);
}
}
#[test]
fn a_query_in_a_script_comment_is_prose() {
let js = "// was matchMedia('(width < 768px)')\n/* (max-width: 500px) */\n";
assert!(js_widths(&blank(js, Lang::Js)).is_empty());
}
#[test]
fn a_brace_inside_a_css_string_does_not_end_a_condition() {
let css = "@media (width < 600px) { a::after { content: \"{\"; } }";
let src = blank(css, Lang::Css);
assert_eq!(media_conditions(&src).len(), 1);
}
#[test]
fn blanking_keeps_byte_offsets_through_multibyte_text() {
let css = "/* a \u{2014} b */ a::after { content: \"\u{2014}\"; }\n@layer reset { * { padding: 0; } }";
let src = blank(css, Lang::Css);
assert_eq!(src.len(), css.len());
let at = css.find("@layer").unwrap();
assert_eq!(&src[at..at + 6], "@layer");
let dropped = drop_layers(css, &["reset"]);
assert!(!dropped.contains("padding"), "{dropped:?}");
}
#[test]
fn a_dropped_layer_keeps_its_important_rules() {
let css =
"@layer reset {\n * { padding: 0; }\n .card { box-shadow: none !important; }\n}\n";
let dropped = drop_layers(css, &["reset"]);
assert!(!dropped.contains("padding"), "{dropped:?}");
assert!(dropped.contains(".card"), "{dropped:?}");
assert!(dropped.contains("box-shadow"), "{dropped:?}");
}
#[test]
fn a_dropped_layer_reads_dotted_sublayers_and_uppercase() {
let dotted = "@layer reset.base {\n .x { color: red; }\n}\n";
assert!(!drop_layers(dotted, &["reset"]).contains("color"));
let upper = "@LAYER reset {\n .x { color: red; }\n}\n";
assert!(!drop_layers(upper, &["reset"]).contains("color"));
}
#[test]
fn blanking_keeps_lines_and_offsets() {
let css = "/* one\ntwo */\n\"x\ny\"\n@media (max-width: 1px) {}";
let src = blank(css, Lang::Css);
assert_eq!(src.len(), css.len());
assert_eq!(src.lines().count(), css.lines().count());
}
#[test]
fn both_quote_styles_read() {
let want = Density::Touch.media_condition();
for q in ['\'', '"'] {
let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
let found = touch_density_literals(&src);
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, want);
}
}
#[test]
fn a_file_list_skips_the_sheets_named_generated() {
let dir = frontend("vocab-files");
write(&dir, "css/layout.css", ".card { box-shadow: none; }\n");
write(&dir, "css/app.css", "body { color: red; }\n");
let files = [dir.join("css/layout.css"), dir.join("css/app.css")];
Vocabulary::files(&files)
.generated(&["layout.css"])
.run(&Emit::default());
let err = std::panic::catch_unwind(|| Vocabulary::files(&files).run(&Emit::default()))
.unwrap_err();
let msg = err.downcast_ref::<String>().cloned().unwrap_or_default();
assert!(msg.contains("box-shadow"), "got: {msg}");
}
}