use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::look::{Context, Finding, Rule, css, markup, script};
pub const EXEMPT_FILE: &str = "look-exempt";
pub const CEILINGS_FILE: &str = "look-ceilings.toml";
const NEVER_WALKED: &[&str] = &["target", "node_modules", "vendor", "dist", "renders"];
#[derive(Debug, Clone)]
pub struct Tree {
root: PathBuf,
generated: Vec<String>,
runtime: Vec<String>,
skipped: Vec<String>,
}
#[derive(Debug)]
pub enum TreeError {
MissingGenerated(PathBuf),
ExemptWithoutReason { line: usize, path: String },
Ceilings(String),
Io(PathBuf, std::io::Error),
}
impl fmt::Display for TreeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingGenerated(p) => write!(
f,
"{} is named as generated and is not on disk; build it first",
p.display()
),
Self::ExemptWithoutReason { line, path } => {
write!(f, "{EXEMPT_FILE} line {line}: `{path}` has no reason")
}
Self::Ceilings(why) => write!(f, "{CEILINGS_FILE}: {why}"),
Self::Io(p, e) => write!(f, "{}: {e}", p.display()),
}
}
}
impl std::error::Error for TreeError {}
#[derive(Debug, Clone, Default)]
pub struct Report {
pub findings: Vec<(PathBuf, Finding)>,
pub ceilings: BTreeMap<Rule, usize>,
pub read: Vec<PathBuf>,
}
impl Report {
#[must_use]
pub fn count(&self, rule: Rule) -> usize {
self.findings.iter().filter(|(_, f)| f.rule == rule).count()
}
fn ceiling(&self, rule: Rule) -> usize {
self.ceilings.get(&rule).copied().unwrap_or(0)
}
#[must_use]
pub fn over(&self) -> Vec<(Rule, usize, usize)> {
Rule::ALL
.iter()
.map(|&r| (r, self.count(r), self.ceiling(r)))
.filter(|(_, n, c)| n > c)
.collect()
}
#[must_use]
pub fn stale(&self) -> Vec<(Rule, usize, usize)> {
Rule::ALL
.iter()
.map(|&r| (r, self.count(r), self.ceiling(r)))
.filter(|(_, n, c)| n < c)
.collect()
}
#[must_use]
pub fn measured_ceilings(&self) -> String {
let lines: Vec<String> = Rule::ALL
.iter()
.map(|&r| (r, self.count(r)))
.filter(|(_, n)| *n > 0)
.map(|(r, n)| format!("{} = {n}", r.id()))
.collect();
format!("[ceiling]\n{}\n", lines.join("\n"))
}
}
impl Tree {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
generated: Vec::new(),
runtime: Vec::new(),
skipped: Vec::new(),
}
}
#[must_use]
pub fn generated(mut self, paths: &[&str]) -> Self {
self.generated
.extend(paths.iter().map(|p| p.trim_start_matches("./").to_string()));
self
}
#[must_use]
pub fn set_at_runtime(mut self, names: &[&str]) -> Self {
self.runtime.extend(names.iter().map(|n| (*n).to_string()));
self
}
#[must_use]
pub fn skip(mut self, dirs: &[&str]) -> Self {
self.skipped.extend(
dirs.iter()
.map(|d| d.trim_start_matches("./").trim_end_matches('/').to_string()),
);
self
}
pub fn check(&self) -> Result<Report, TreeError> {
let exempt = self.exempt()?;
let ceilings = self.ceilings()?;
let mut report = Report {
ceilings,
..Report::default()
};
let mut generated_css = Vec::new();
for g in &self.generated {
let path = self.root.join(g);
if !path.exists() {
return Err(TreeError::MissingGenerated(path));
}
if path.extension().is_some_and(|e| e == "css") {
generated_css.push(read(&path)?);
}
}
let refs: Vec<&str> = generated_css.iter().map(String::as_str).collect();
let mut ctx = Context::generated(&refs);
ctx.set_at_runtime(self.runtime.iter().map(String::as_str));
let mut files = Vec::new();
self.walk(&self.root, &mut files)?;
files.retain(|rel| {
let s = slash(rel);
!self.generated.contains(&s) && !exempt.iter().any(|e| matches(e, &s))
});
files.sort();
let mut texts = Vec::with_capacity(files.len());
for rel in &files {
texts.push(read(&self.root.join(rel))?);
}
for (rel, text) in files.iter().zip(&texts) {
if kind(rel) == Some(Kind::Sheet) {
ctx.link(text);
}
}
for (rel, text) in files.iter().zip(&texts) {
let found = match kind(rel) {
Some(Kind::Sheet) => css(text, &ctx),
Some(Kind::Markup) => markup(text),
Some(Kind::Script) => script(text),
Some(Kind::Manifest) => manifest(text),
None => continue,
};
report
.findings
.extend(found.into_iter().map(|f| (rel.clone(), f)));
}
report.read = files;
Ok(report)
}
pub fn gate(&self) {
println!(
"cargo::rerun-if-changed={}",
self.root.join(EXEMPT_FILE).display()
);
println!(
"cargo::rerun-if-changed={}",
self.root.join(CEILINGS_FILE).display()
);
let report = self.check().unwrap_or_else(|e| panic!("look gate: {e}"));
for rel in &report.read {
println!("cargo::rerun-if-changed={}", self.root.join(rel).display());
}
for (rule, n, c) in report.stale() {
println!(
"cargo::warning=look gate: {rule} is at {n}, under its ceiling of {c}; lower it in {CEILINGS_FILE}"
);
}
let over = report.over();
if over.is_empty() {
return;
}
let lines: Vec<String> = over
.iter()
.flat_map(|(rule, n, c)| {
std::iter::once(format!(" {rule}: {n} found, ceiling {c}")).chain(
report
.findings
.iter()
.filter(move |(_, f)| f.rule == *rule)
.map(|(rel, f)| {
format!(" {}:{}: {}", slash(rel), f.line, f.declaration)
}),
)
})
.collect();
panic!(
"look gate: over the ceiling\n{}\nTake the value from a token, or name the file in look-exempt with a reason.",
lines.join("\n")
);
}
fn walk(&self, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), TreeError> {
let entries = std::fs::read_dir(dir).map_err(|e| TreeError::Io(dir.to_path_buf(), e))?;
for entry in entries {
let entry = entry.map_err(|e| TreeError::Io(dir.to_path_buf(), e))?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
let rel = path.strip_prefix(&self.root).unwrap_or(&path).to_path_buf();
if path.is_dir() {
let skip = name.starts_with('.')
|| NEVER_WALKED.contains(&name.as_ref())
|| self.skipped.iter().any(|s| *s == slash(&rel));
if !skip {
self.walk(&path, out)?;
}
} else if kind(&rel).is_some() {
out.push(rel);
}
}
Ok(())
}
fn exempt(&self) -> Result<Vec<String>, TreeError> {
let path = self.root.join(EXEMPT_FILE);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (entry, reason) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
if reason.trim().is_empty() {
return Err(TreeError::ExemptWithoutReason {
line: i + 1,
path: entry.to_string(),
});
}
out.push(entry.trim_start_matches("./").to_string());
}
Ok(out)
}
fn ceilings(&self) -> Result<BTreeMap<Rule, usize>, TreeError> {
#[derive(serde::Deserialize)]
struct File {
#[serde(default)]
ceiling: BTreeMap<String, usize>,
}
let path = self.root.join(CEILINGS_FILE);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(BTreeMap::new());
};
let file: File = toml::from_str(&text).map_err(|e| TreeError::Ceilings(e.to_string()))?;
file.ceiling
.into_iter()
.map(|(id, n)| {
Rule::from_id(&id)
.map(|r| (r, n))
.ok_or_else(|| TreeError::Ceilings(format!("no rule `{id}`")))
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Sheet,
Markup,
Script,
Manifest,
}
fn kind(rel: &Path) -> Option<Kind> {
let name = rel.file_name()?.to_string_lossy().to_ascii_lowercase();
if name == "manifest.json" || name.ends_with(".webmanifest") {
return Some(Kind::Manifest);
}
if name.ends_with(".min.js") || name.ends_with(".d.ts") {
return None;
}
match rel
.extension()?
.to_string_lossy()
.to_ascii_lowercase()
.as_str()
{
"css" => Some(Kind::Sheet),
"html" | "htm" | "svg" => Some(Kind::Markup),
"js" | "mjs" | "ts" | "tsx" => Some(Kind::Script),
_ => None,
}
}
fn manifest(text: &str) -> Vec<Finding> {
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.trim().trim_matches('"').ends_with("_color") && value.contains('#') {
out.push(Finding {
rule: Rule::Colour,
line: i + 1,
declaration: line.trim().trim_end_matches(',').to_string(),
});
}
}
out
}
fn read(path: &Path) -> Result<String, TreeError> {
std::fs::read_to_string(path).map_err(|e| TreeError::Io(path.to_path_buf(), e))
}
fn slash(rel: &Path) -> String {
rel.to_string_lossy().replace('\\', "/")
}
fn matches(pattern: &str, path: &str) -> bool {
if let Some(dir) = pattern.strip_suffix('/') {
return path.starts_with(&format!("{dir}/"));
}
let p: Vec<&str> = pattern.split('/').collect();
let s: Vec<&str> = path.split('/').collect();
p.len() == s.len() && p.iter().zip(&s).all(|(p, s)| glob(p, s))
}
fn glob(pattern: &str, text: &str) -> bool {
match pattern.split_once('*') {
None => pattern == text,
Some((head, tail)) => {
text.starts_with(head)
&& (0..=text.len() - head.len()).any(|i| {
let rest = &text[head.len() + i..];
glob(tail, rest)
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree_with(files: &[(&str, &str)]) -> (tempdir::Dir, Tree) {
let dir = tempdir::Dir::new();
for (rel, text) in files {
let path = dir.path().join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let tree = Tree::new(dir.path());
(dir, tree)
}
#[test]
fn every_web_file_is_read_but_the_generated_and_the_exempt() {
let (_d, tree) = tree_with(&[
("static/geometry.css", ":root { --gap: 1rem; }"),
(
"css/new-page.css",
".a { color: #fff; padding: var(--gap); }",
),
("templates/page.html", "<p style=\"color: red\">x</p>"),
("frontend/src/player.ts", "el.style.width = '3px';"),
(
"static/manifest.json",
"{\n \"theme_color\": \"#070605\"\n}",
),
("static/favicon.svg", "<svg><path fill=\"#1B365D\"/></svg>"),
("node_modules/x/y.css", ".b { color: red; }"),
(
"look-exempt",
"static/favicon.svg the brand mark keeps its own colours\n",
),
]);
let report = tree.generated(&["static/geometry.css"]).check().unwrap();
let mut seen: Vec<(String, Rule)> = report
.findings
.iter()
.map(|(p, f)| (slash(p), f.rule))
.collect();
seen.sort();
assert_eq!(
seen,
vec![
("css/new-page.css".into(), Rule::Colour),
("frontend/src/player.ts".into(), Rule::ScriptStyle),
("static/manifest.json".into(), Rule::Colour),
("templates/page.html".into(), Rule::InlineStyle),
]
);
}
#[test]
fn a_missing_generated_sheet_refuses_the_check() {
let (_d, tree) = tree_with(&[("css/a.css", ".a { color: var(--content); }")]);
let err = tree.generated(&["static/palette.css"]).check().unwrap_err();
assert!(matches!(err, TreeError::MissingGenerated(_)), "{err}");
}
#[test]
fn an_exemption_needs_a_reason() {
let (_d, tree) = tree_with(&[("look-exempt", "static/favicon.svg\n")]);
assert!(matches!(
tree.check().unwrap_err(),
TreeError::ExemptWithoutReason { line: 1, .. }
));
}
#[test]
fn ceilings_hold_the_count_and_only_move_down() {
let (_d, tree) = tree_with(&[
(
"a.css",
".a { color: #fff; } .b { color: #000; } .c { padding: 2px; }",
),
("look-ceilings.toml", "[ceiling]\ncolour = 1\nlength = 3\n"),
]);
let report = tree.check().unwrap();
assert_eq!(report.over(), vec![(Rule::Colour, 2, 1)]);
assert_eq!(report.stale(), vec![(Rule::Length, 1, 3)]);
assert_eq!(
report.measured_ceilings(),
"[ceiling]\ncolour = 2\nlength = 1\n"
);
let (_d, bad) = tree_with(&[("look-ceilings.toml", "[ceiling]\ncolor = 1\n")]);
assert!(matches!(bad.check().unwrap_err(), TreeError::Ceilings(_)));
}
#[test]
fn sheets_of_one_tree_read_each_others_tokens() {
let (_d, tree) = tree_with(&[
("css/00-tokens.css", ":root { --track-gutter: 4rem; }"),
(
"css/10-day.css",
".day { padding-inline-start: var(--track-gutter); }",
),
("other/x.css", ":root { --elsewhere: 1rem; }"),
("css/20-x.css", ".x { margin: var(--elsewhere); }"),
]);
let report = tree.skip(&["other"]).check().unwrap();
let undefined: Vec<String> = report
.findings
.iter()
.filter(|(_, f)| f.rule == Rule::UndefinedVar)
.map(|(p, _)| slash(p))
.collect();
assert_eq!(undefined, vec!["css/20-x.css"]);
}
#[test]
fn an_exempt_pattern_matches_within_one_segment() {
assert!(matches("static/patterns/*.svg", "static/patterns/dots.svg"));
assert!(!matches(
"static/patterns/*.svg",
"static/patterns/deep/dots.svg"
));
assert!(matches("mockups/", "mockups/weekly-review.html"));
assert!(matches("media/logo-go*.svg", "media/logo-go-mark.svg"));
assert!(!matches("media/logo-go*.svg", "media/logo.svg"));
}
mod tempdir {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
pub(super) struct Dir(PathBuf);
impl Dir {
pub(super) fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"makeover-look-tree-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
pub(super) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
}