| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
use std::path::{Path, PathBuf}; |
| 15 |
|
| 16 |
use makeover_geometry::Density; |
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
const CONST_NAME: &str = "TOUCH_DENSITY"; |
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"]; |
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
pub fn check_touch_density(js_dir: impl AsRef<Path>) { |
| 50 |
let js_dir = js_dir.as_ref(); |
| 51 |
let want = Density::Touch.media_condition(); |
| 52 |
let mut wrong: Vec<String> = Vec::new(); |
| 53 |
let mut found = 0usize; |
| 54 |
|
| 55 |
let files = js_files(js_dir); |
| 56 |
for path in &files { |
| 57 |
let src = std::fs::read_to_string(path).expect("read js file"); |
| 58 |
let name = path |
| 59 |
.strip_prefix(js_dir) |
| 60 |
.unwrap_or(path) |
| 61 |
.display() |
| 62 |
.to_string(); |
| 63 |
|
| 64 |
for (offset, literal) in touch_density_literals(&src) { |
| 65 |
found += 1; |
| 66 |
if literal != want { |
| 67 |
wrong.push(format!( |
| 68 |
" {name}:{} {CONST_NAME} = '{literal}'", |
| 69 |
line_of(&src, offset) |
| 70 |
)); |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
for needle in SNIFFS { |
| 75 |
if let Some(offset) = src.find(needle) { |
| 76 |
wrong.push(format!( |
| 77 |
" {name}:{} {needle} -- device sniff, not a density question", |
| 78 |
line_of(&src, offset) |
| 79 |
)); |
| 80 |
} |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
assert!( |
| 85 |
found > 0, |
| 86 |
"no {CONST_NAME} literal found under {}.\n\n\ |
| 87 |
A frontend that asks whether it is being touched states\n\ |
| 88 |
makeover_geometry::Density::Touch's media condition in a const of that\n\ |
| 89 |
name, and this check exists to keep every copy equal to it. If the\n\ |
| 90 |
const was renamed, rename it back rather than dropping the check; if\n\ |
| 91 |
this frontend genuinely asks no density question, drop the call.", |
| 92 |
js_dir.display() |
| 93 |
); |
| 94 |
|
| 95 |
assert!( |
| 96 |
wrong.is_empty(), |
| 97 |
"hand-written touch detection disagrees with makeover_geometry::Density.\n\n\ |
| 98 |
Density::Touch.media_condition() is: {want}\n\n\ |
| 99 |
Wrong:\n{}\n\n\ |
| 100 |
Fix the JS to state the crate's string. Never widen it to catch a\n\ |
| 101 |
device the query misses: density is what is pointing at the screen,\n\ |
| 102 |
and a laptop with a touchscreen and a mouse is a pointer device.", |
| 103 |
wrong.join("\n") |
| 104 |
); |
| 105 |
|
| 106 |
for path in &files { |
| 107 |
println!("cargo:rerun-if-changed={}", path.display()); |
| 108 |
} |
| 109 |
} |
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
fn js_files(dir: &Path) -> Vec<PathBuf> { |
| 118 |
let mut out = Vec::new(); |
| 119 |
let mut stack = vec![dir.to_path_buf()]; |
| 120 |
while let Some(d) = stack.pop() { |
| 121 |
for entry in std::fs::read_dir(&d) |
| 122 |
.unwrap_or_else(|e| panic!("read {}: {e}", d.display())) |
| 123 |
.flatten() |
| 124 |
{ |
| 125 |
let path = entry.path(); |
| 126 |
if path.is_dir() { |
| 127 |
stack.push(path); |
| 128 |
} else if path.extension().is_some_and(|x| x == "js") { |
| 129 |
out.push(path); |
| 130 |
} |
| 131 |
} |
| 132 |
} |
| 133 |
out.sort(); |
| 134 |
out |
| 135 |
} |
| 136 |
|
| 137 |
|
| 138 |
|
| 139 |
fn touch_density_literals(src: &str) -> Vec<(usize, &str)> { |
| 140 |
let mut out = Vec::new(); |
| 141 |
let mut at = 0; |
| 142 |
while let Some(i) = src[at..].find(CONST_NAME) { |
| 143 |
let start = at + i; |
| 144 |
at = start + CONST_NAME.len(); |
| 145 |
|
| 146 |
let Some(rest) = src[at..].strip_prefix(" = ") else { |
| 147 |
continue; |
| 148 |
}; |
| 149 |
let open = at + " = ".len(); |
| 150 |
let Some(quote @ ('\'' | '"')) = rest.chars().next() else { |
| 151 |
continue; |
| 152 |
}; |
| 153 |
let body = open + 1; |
| 154 |
if let Some(j) = src[body..].find(quote) { |
| 155 |
out.push((start, &src[body..body + j])); |
| 156 |
at = body + j + 1; |
| 157 |
} |
| 158 |
} |
| 159 |
out |
| 160 |
} |
| 161 |
|
| 162 |
fn line_of(src: &str, offset: usize) -> usize { |
| 163 |
src[..offset].matches('\n').count() + 1 |
| 164 |
} |
| 165 |
|
| 166 |
#[cfg(test)] |
| 167 |
mod tests { |
| 168 |
use super::*; |
| 169 |
|
| 170 |
fn scratch(name: &str) -> PathBuf { |
| 171 |
let dir = |
| 172 |
std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id())); |
| 173 |
let _ = std::fs::remove_dir_all(&dir); |
| 174 |
std::fs::create_dir_all(&dir).expect("create scratch"); |
| 175 |
dir |
| 176 |
} |
| 177 |
|
| 178 |
fn write(dir: &Path, name: &str, src: &str) { |
| 179 |
if let Some(parent) = dir.join(name).parent() { |
| 180 |
std::fs::create_dir_all(parent).unwrap(); |
| 181 |
} |
| 182 |
std::fs::write(dir.join(name), src).unwrap(); |
| 183 |
} |
| 184 |
|
| 185 |
fn declaring() -> String { |
| 186 |
format!( |
| 187 |
"const {CONST_NAME} = '{}';\n", |
| 188 |
Density::Touch.media_condition() |
| 189 |
) |
| 190 |
} |
| 191 |
|
| 192 |
#[test] |
| 193 |
fn the_crates_own_string_passes() { |
| 194 |
let dir = scratch("ok"); |
| 195 |
write(&dir, "touch.js", &declaring()); |
| 196 |
check_touch_density(&dir); |
| 197 |
} |
| 198 |
|
| 199 |
#[test] |
| 200 |
#[should_panic(expected = "disagrees with makeover_geometry::Density")] |
| 201 |
fn a_drifted_literal_fails() { |
| 202 |
let dir = scratch("drift"); |
| 203 |
write(&dir, "touch.js", &declaring()); |
| 204 |
write( |
| 205 |
&dir, |
| 206 |
"haptics.js", |
| 207 |
&format!("const {CONST_NAME} = '(pointer: coarse)';\n"), |
| 208 |
); |
| 209 |
check_touch_density(&dir); |
| 210 |
} |
| 211 |
|
| 212 |
#[test] |
| 213 |
#[should_panic(expected = "device sniff")] |
| 214 |
fn the_sniff_cannot_come_back() { |
| 215 |
let dir = scratch("sniff"); |
| 216 |
write(&dir, "touch.js", &declaring()); |
| 217 |
write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n"); |
| 218 |
check_touch_density(&dir); |
| 219 |
} |
| 220 |
|
| 221 |
#[test] |
| 222 |
#[should_panic(expected = "no TOUCH_DENSITY literal found")] |
| 223 |
fn a_frontend_that_states_nothing_fails() { |
| 224 |
let dir = scratch("empty"); |
| 225 |
write(&dir, "app.js", "export const x = 1;\n"); |
| 226 |
check_touch_density(&dir); |
| 227 |
} |
| 228 |
|
| 229 |
#[test] |
| 230 |
fn a_use_site_is_not_a_declaration() { |
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
let src = |
| 235 |
format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n"); |
| 236 |
assert!(touch_density_literals(&src).is_empty()); |
| 237 |
} |
| 238 |
|
| 239 |
#[test] |
| 240 |
fn nested_files_are_read() { |
| 241 |
|
| 242 |
|
| 243 |
let dir = scratch("nested"); |
| 244 |
write(&dir, "touch.js", &declaring()); |
| 245 |
write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n"); |
| 246 |
let files = js_files(&dir); |
| 247 |
assert_eq!(files.len(), 2); |
| 248 |
} |
| 249 |
|
| 250 |
#[test] |
| 251 |
fn a_non_js_file_is_ignored() { |
| 252 |
let dir = scratch("nonjs"); |
| 253 |
write(&dir, "touch.js", &declaring()); |
| 254 |
write(&dir, "styles.css", "body { }\n"); |
| 255 |
assert_eq!(js_files(&dir).len(), 1); |
| 256 |
} |
| 257 |
|
| 258 |
#[test] |
| 259 |
fn both_quote_styles_read() { |
| 260 |
let want = Density::Touch.media_condition(); |
| 261 |
for q in ['\'', '"'] { |
| 262 |
let src = format!("const {CONST_NAME} = {q}{want}{q};\n"); |
| 263 |
let found = touch_density_literals(&src); |
| 264 |
assert_eq!(found.len(), 1); |
| 265 |
assert_eq!(found[0].1, want); |
| 266 |
} |
| 267 |
} |
| 268 |
} |
| 269 |
|