use std::path::{Path, PathBuf};
use makeover_geometry::{Density, SizeClass};
use makeover_webview::Emit;
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 src = std::fs::read_to_string(path).expect("read js file");
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());
}
}
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();
let Some(rest) = src[at..].strip_prefix(" = ") else {
continue;
};
let open = at + " = ".len();
let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
continue;
};
let body = open + 1;
if let Some(j) = src[body..].find(quote) {
out.push((start, &src[body..body + j]));
at = body + j + 1;
}
}
out
}
fn line_of(src: &str, offset: usize) -> usize {
src[..offset].matches('\n').count() + 1
}
pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
let frontend = frontend.as_ref();
let mut files = files_with_extension(&frontend.join("css"), "css");
files.extend(js_files(&frontend.join("js")));
check_paths(&files, tuning_widths, Some(frontend));
}
pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
check_paths(&paths, tuning_widths, None);
}
fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
let allowed = allowed_widths(tuning_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, px) in js_widths(&raw) {
if !allowed.contains(&px) {
stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset)));
}
}
continue;
}
let src = strip_block_comments(&raw);
for (offset, condition) in media_conditions(&src) {
for px in media_widths(condition) {
if !allowed.contains(&px) {
stale.push(format!(
" {name}:{} @media{condition} ({px}px)",
line_of(&src, offset)
));
}
}
}
}
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\
If a size class moved, 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: Vec<u16> = SizeClass::all()
.iter()
.flat_map(|c| media_widths(&c.media_condition()))
.collect();
widths.extend_from_slice(tuning_widths);
widths.sort_unstable();
widths.dedup();
widths
}
fn media_widths(condition: &str) -> Vec<u16> {
let mut out = Vec::new();
let mut rest = condition;
while let Some(i) = rest.find("-width:") {
rest = &rest[i + "-width:".len()..];
let digits: String = rest
.trim_start()
.chars()
.take_while(char::is_ascii_digit)
.collect();
if let Ok(px) = digits.parse() {
out.push(px);
}
}
out
}
fn media_conditions(css: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut at = 0;
while let Some(i) = css[at..].find("@media") {
let start = at + i;
let after = start + "@media".len();
match css[after..].find('{') {
Some(j) => {
out.push((start, &css[after..after + j]));
at = after + j;
}
None => break,
}
}
out
}
fn js_widths(src: &str) -> Vec<(usize, u16)> {
let mut out = Vec::new();
for pat in ["(max-width:", "(min-width:"] {
let mut at = 0;
while let Some(i) = src[at..].find(pat) {
let start = at + i;
let rest = src[start + pat.len()..].trim_start();
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if let Ok(px) = digits.parse()
&& rest[digits.len()..].starts_with("px)")
{
out.push((start, px));
}
at = start + pat.len();
}
}
out
}
fn strip_block_comments(css: &str) -> String {
let bytes = css.as_bytes();
let mut out = String::with_capacity(css.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(b"/*") {
let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
for c in css[i..end].chars() {
out.push(if c == '\n' { '\n' } else { ' ' });
}
i = end;
} else {
let c = css[i..].chars().next().unwrap();
out.push(c);
i += c.len_utf8();
}
}
out
}
pub fn check_vocabulary(
frontend: impl AsRef<Path>,
opts: &Emit,
generated: &[&str],
allowed: &[(&str, &str)],
) {
let frontend = frontend.as_ref();
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();
!generated.contains(&name.as_str())
})
.collect();
check_vocabulary_paths(&files, opts, Some(frontend), allowed);
}
pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit, allowed: &[(&str, &str)]) {
let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
check_vocabulary_paths(&paths, opts, None, allowed);
}
fn check_vocabulary_paths(
paths: &[PathBuf],
opts: &Emit,
root: Option<&Path>,
allowed: &[(&str, &str)],
) {
let generated =
makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
let mut clashes: Vec<String> = Vec::new();
let mut seen: Vec<(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 local = makeover_webview::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} }}"));
}
}
}
assert!(
clashes.is_empty(),
"{} hand-written declaration(s) take a property the generated stylesheet \
already sets on the same class. App CSS is unlayered and beats \
@layer makeover, 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")
);
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."
);
}
pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
let generated = makeover_webview::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');
}
let unused: Vec<&String> = generated
.iter()
.filter(|class| !haystack.contains(class.as_str()))
.collect();
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::*;
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 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()),
);
check_breakpoints(&dir, &[]);
}
#[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");
check_breakpoints(&dir, &[]);
}
#[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");
check_breakpoints(&dir, &[]);
}
#[test]
fn a_declared_tuning_width_passes() {
let dir = frontend("bp-tuning");
write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
check_breakpoints(&dir, &[1400]);
}
#[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",
);
check_breakpoints(&dir, &[]);
}
#[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",
);
check_breakpoints(&dir, &[]);
}
#[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(|| check_breakpoints(&dir, &[]));
assert!(found.is_err(), "a nested stylesheet must be scanned");
}
#[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(|| check_breakpoints_files(&[&listed], &[])).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");
check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
}
#[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",
);
check_breakpoints_files(&[dir.join("js/style.js")], &[]);
}
#[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(|| check_breakpoints(&dir, &[])).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_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(|| check_vocabulary(&dir, &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",
);
check_vocabulary(&dir, &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", &makeover_webview::stylesheet(&opts));
check_vocabulary(&dir, &opts, &["layout.css"], &[]);
}
#[test]
fn a_prefixed_app_is_checked_against_its_own_prefix() {
let dir = scratch("vocab-prefix");
let opts = Emit {
class_prefix: "mo-",
..Emit::default()
};
write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
check_vocabulary(&dir, &opts, &[], &[]);
let dir = scratch("vocab-prefix-clash");
write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &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",
".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
);
check_vocabulary(&dir, &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", ".card { box-shadow: none; }\n");
check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
let dir = scratch("vocab-allowed-stale");
write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
let err = std::panic::catch_unwind(|| {
check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
})
.unwrap_err();
let msg = err
.downcast_ref::<String>()
.expect("panic payload is a String");
assert!(msg.contains("no longer collides"), "got: {msg}");
}
#[test]
fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
let dir = scratch("vocab-seal");
let opts = Emit::default();
let all = makeover_webview::vocabulary::names(&opts).len();
write(&dir, "index.html", "<div></div>\n");
let markup = [dir.join("index.html")];
check_vocabulary_use(&markup, &opts, all);
assert!(
std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
"a vocabulary deader than the seal has to fail"
);
}
#[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);
}
}
}