use std::collections::BTreeSet;
use crate::literals::{blank_unread, writes_colour};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Rule {
Colour,
OpacityInk,
MixedTint,
Length,
Duration,
Face,
TokenShadow,
UndefinedVar,
InlineStyle,
StyleBlock,
SvgPaint,
ScriptStyle,
}
impl Rule {
pub const ALL: &[Self] = &[
Self::Colour,
Self::OpacityInk,
Self::MixedTint,
Self::Length,
Self::Duration,
Self::Face,
Self::TokenShadow,
Self::UndefinedVar,
Self::InlineStyle,
Self::StyleBlock,
Self::SvgPaint,
Self::ScriptStyle,
];
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Colour => "colour",
Self::OpacityInk => "opacity-ink",
Self::MixedTint => "mixed-tint",
Self::Length => "length",
Self::Duration => "duration",
Self::Face => "face",
Self::TokenShadow => "token-shadow",
Self::UndefinedVar => "undefined-var",
Self::InlineStyle => "inline-style",
Self::StyleBlock => "style-block",
Self::SvgPaint => "svg-paint",
Self::ScriptStyle => "script-style",
}
}
#[must_use]
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|r| r.id() == id)
}
}
impl std::fmt::Display for Rule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.id())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub rule: Rule,
pub line: usize,
pub declaration: String,
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"line {} [{}]: {}",
self.line, self.rule, self.declaration
)
}
}
#[derive(Debug, Clone, Default)]
pub struct Context {
generated: BTreeSet<String>,
defined: BTreeSet<String>,
}
impl Context {
#[must_use]
pub fn generated(sheets: &[&str]) -> Self {
let mut ctx = Self::default();
for sheet in sheets {
for name in custom_properties(sheet) {
ctx.generated.insert(name.clone());
ctx.defined.insert(name);
}
}
ctx
}
pub fn link(&mut self, sheet: &str) {
self.defined.extend(custom_properties(sheet));
}
pub fn set_at_runtime<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
self.defined.extend(
names
.into_iter()
.map(|n| n.trim_start_matches('-').to_string()),
);
}
}
#[must_use]
pub fn css(css: &str, ctx: &Context) -> Vec<Finding> {
let text = blank_unread(css);
let decls = declarations(&text);
let mut own: BTreeSet<String> = ctx.defined.clone();
own.extend(
decls
.iter()
.filter_map(|d| d.property.strip_prefix("--").map(str::to_string)),
);
let mut found = Vec::new();
for d in &decls {
for rule in rules_hit(d, ctx, &own) {
found.push(Finding {
rule,
line: d.line,
declaration: d.text.clone(),
});
}
}
found
}
fn rules_hit(d: &Decl, ctx: &Context, defined: &BTreeSet<String>) -> Vec<Rule> {
let p = d.property.as_str();
let v = d.value.as_str();
let bare = blank_var_args(v);
let mut hit = Vec::new();
if writes_colour(p, v) {
hit.push(Rule::Colour);
}
if p == "opacity" && !d.in_keyframes && opacity_below_one(v) {
hit.push(Rule::OpacityInk);
}
if v.contains("color-mix(") && var_names(v).any(|n| TONES.contains(&n)) {
hit.push(Rule::MixedTint);
}
if takes_scale_length(p) && has_unit(&bare, LENGTH_UNITS) {
hit.push(Rule::Length);
}
if (p.starts_with("transition") || p.starts_with("animation")) && has_unit(&bare, TIME_UNITS) {
hit.push(Rule::Duration);
}
if p == "font-family" && !is_token_or_keyword(v) {
hit.push(Rule::Face);
}
if let Some(name) = p.strip_prefix("--")
&& ctx.generated.contains(name)
{
hit.push(Rule::TokenShadow);
}
if bare_vars(v).any(|n| !defined.contains(n)) {
hit.push(Rule::UndefinedVar);
}
hit
}
const TONES: &[&str] = &["danger", "success", "warning", "info", "action"];
const LENGTH_UNITS: &[&str] = &[
"px", "rem", "em", "ch", "ex", "pt", "pc", "cm", "mm", "in", "q",
];
const TIME_UNITS: &[&str] = &["ms", "s"];
fn takes_scale_length(p: &str) -> bool {
const EXACT: &[&str] = &[
"font-size",
"box-shadow",
"gap",
"row-gap",
"column-gap",
"max-width",
"max-inline-size",
];
const STEMS: &[&str] = &["border", "outline", "padding", "margin"];
EXACT.contains(&p)
|| STEMS
.iter()
.any(|s| p == *s || p.starts_with(&format!("{s}-")))
}
struct Decl {
line: usize,
property: String,
value: String,
text: String,
in_keyframes: bool,
}
fn declarations(text: &str) -> Vec<Decl> {
let mut out = Vec::new();
let mut preludes: Vec<String> = Vec::new();
let mut start = 0;
for (at, c) in text.char_indices() {
match c {
'{' => {
preludes.push(text[start..at].trim().to_ascii_lowercase());
start = at + 1;
}
';' | '}' => {
let in_keyframes = preludes.iter().any(|p| p.contains("keyframes"));
if let Some(d) = declaration(text, start, at, in_keyframes) {
out.push(d);
}
if c == '}' {
preludes.pop();
}
start = at + 1;
}
_ => {}
}
}
out
}
fn declaration(text: &str, start: usize, end: usize, in_keyframes: bool) -> Option<Decl> {
let segment = &text[start..end];
let colon = segment.find(':')?;
let property = segment[..colon].trim().to_ascii_lowercase();
if property.is_empty() || property.contains(char::is_whitespace) || property.starts_with('@') {
return None;
}
let lead = segment.len() - segment.trim_start().len();
Some(Decl {
line: text[..start + lead].matches('\n').count() + 1,
property,
value: segment[colon + 1..].trim().to_string(),
text: segment.split_whitespace().collect::<Vec<_>>().join(" "),
in_keyframes,
})
}
fn custom_properties(css: &str) -> Vec<String> {
declarations(&blank_unread(css))
.into_iter()
.filter_map(|d| d.property.strip_prefix("--").map(str::to_string))
.collect()
}
fn opacity_below_one(value: &str) -> bool {
let v = value.trim();
let n = v
.strip_suffix('%')
.and_then(|p| p.trim().parse::<f64>().ok().map(|p| p / 100.0))
.or_else(|| v.parse::<f64>().ok());
n.is_some_and(|n| n > 0.0 && n < 1.0)
}
fn is_token_or_keyword(value: &str) -> bool {
let v = value.trim().to_ascii_lowercase();
v.starts_with("var(") || matches!(v.as_str(), "inherit" | "initial" | "unset" | "revert")
}
fn blank_var_args(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut depth = 0usize;
let mut i = 0;
let b = value.as_bytes();
while i < b.len() {
if depth == 0 && value[i..].starts_with("var(") {
out.push_str("var(");
i += 4;
depth = 1;
continue;
}
let c = value[i..].chars().next().unwrap_or(' ');
if depth > 0 {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
out.push(if depth == 0 { ')' } else { ' ' });
} else {
out.push(c);
}
i += c.len_utf8();
}
out
}
fn has_unit(value: &str, units: &[&str]) -> bool {
let b = value.as_bytes();
let mut i = 0;
while i < b.len() {
let starts = (b[i].is_ascii_digit() || b[i] == b'.')
&& (i == 0
|| !(b[i - 1].is_ascii_alphanumeric() || matches!(b[i - 1], b'_' | b'#' | b'.')));
if !starts {
i += 1;
continue;
}
let num_end = i + b[i..]
.iter()
.take_while(|c| c.is_ascii_digit() || **c == b'.')
.count();
let unit_end = num_end
+ b[num_end..]
.iter()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let unit = value[num_end..unit_end].to_ascii_lowercase();
let nonzero = value[i..num_end]
.bytes()
.any(|c| (b'1'..=b'9').contains(&c));
if nonzero && units.contains(&unit.as_str()) {
return true;
}
i = unit_end.max(i + 1);
}
false
}
fn var_names(value: &str) -> impl Iterator<Item = &str> {
value.match_indices("var(").filter_map(move |(at, _)| {
let rest = value[at + 4..].trim_start().strip_prefix("--")?;
let len = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(rest.len());
Some(&rest[..len])
})
}
fn bare_vars(value: &str) -> impl Iterator<Item = &str> {
value.match_indices("var(").filter_map(move |(at, _)| {
let rest = value[at + 4..].trim_start().strip_prefix("--")?;
let len = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(rest.len());
rest[len..]
.trim_start()
.starts_with(')')
.then_some(&rest[..len])
})
}
#[must_use]
pub fn markup(html: &str) -> Vec<Finding> {
let text = blank_between(&blank_between(html, "<!--", "-->"), "{#", "#}");
let lower = text.to_ascii_lowercase();
let mut found = Vec::new();
let mut push = |rule, at: usize, quote: &str| {
found.push(Finding {
rule,
line: text[..at].matches('\n').count() + 1,
declaration: quote.split_whitespace().collect::<Vec<_>>().join(" "),
});
};
for (at, _) in lower.match_indices("<style") {
let next = lower[at + 6..].chars().next();
if next.is_some_and(|c| c.is_whitespace() || c == '>') {
push(Rule::StyleBlock, at, "<style>");
}
}
for (name, rule) in [
("style", Rule::InlineStyle),
("fill", Rule::SvgPaint),
("stroke", Rule::SvgPaint),
] {
for (at, value) in attributes(&text, &lower, name) {
let writes = match rule {
Rule::InlineStyle => !only_custom_properties(value),
_ => !is_inherited_paint(value),
};
if writes {
push(rule, at, &format!("{name}=\"{value}\""));
}
}
}
found.sort_by_key(|f| f.line);
found
}
#[must_use]
pub fn script(js: &str) -> Vec<Finding> {
let text = blank_script_comments(js);
let mut found = Vec::new();
let line_of = |at: usize| text[..at].matches('\n').count() + 1;
let quote_at = |at: usize| {
let start = text[..at].rfind('\n').map_or(0, |n| n + 1);
let end = text[at..].find('\n').map_or(text.len(), |n| at + n);
text[start..end].trim().to_string()
};
for (at, _) in text.match_indices(".style") {
let rest = &text[at + 6..];
let hit = if let Some(after) = rest.strip_prefix(".setProperty(") {
!after
.trim_start()
.trim_start_matches(['\'', '"', '`'])
.starts_with("--")
} else if let Some(after) = rest.strip_prefix('.') {
let len = after
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(after.len());
let tail = after[len..].trim_start();
len > 0 && tail.starts_with('=') && !tail.starts_with("==")
} else {
false
};
if hit {
found.push(Finding {
rule: Rule::ScriptStyle,
line: line_of(at),
declaration: quote_at(at),
});
}
}
for needle in [
"setAttribute('style'",
"setAttribute(\"style\"",
"setAttribute(`style`",
] {
for (at, _) in text.match_indices(needle) {
found.push(Finding {
rule: Rule::ScriptStyle,
line: line_of(at),
declaration: quote_at(at),
});
}
}
for (at, lit) in string_literals(&text) {
let lower = lit.to_ascii_lowercase();
let carries = attributes(lit, &lower, "style")
.into_iter()
.any(|(_, v)| !only_custom_properties(v));
if carries {
found.push(Finding {
rule: Rule::ScriptStyle,
line: line_of(at),
declaration: quote_at(at),
});
}
}
found.sort_by_key(|f| f.line);
found.dedup_by(|a, b| a.line == b.line && a.declaration == b.declaration);
found
}
fn attributes<'a>(text: &'a str, lower: &str, name: &str) -> Vec<(usize, &'a str)> {
let mut out = Vec::new();
let pat = format!("{name}=");
for (at, _) in lower.match_indices(&pat) {
let before = lower[..at].chars().next_back();
if !before.is_some_and(char::is_whitespace) {
continue;
}
let rest = &text[at + pat.len()..];
let Some(quote) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else {
continue;
};
let Some(end) = rest[1..].find(quote) else {
continue;
};
out.push((at, &rest[1..=end]));
}
out
}
fn only_custom_properties(style: &str) -> bool {
style
.split(';')
.map(str::trim)
.filter(|d| !d.is_empty())
.all(|d| d.starts_with("--") || d.starts_with("{{") || d.starts_with("{%"))
}
fn is_inherited_paint(value: &str) -> bool {
let v = value.trim().to_ascii_lowercase();
matches!(
v.as_str(),
"currentcolor" | "none" | "transparent" | "context-fill" | "context-stroke"
) || v.starts_with("var(")
|| v.starts_with("url(")
|| v.starts_with("{{")
}
fn blank_between(text: &str, open: &str, close: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find(open) {
out.push_str(&rest[..start]);
let end = rest[start..]
.find(close)
.map_or(rest.len(), |e| start + e + close.len());
out.extend(
rest[start..end]
.chars()
.map(|c| if c == '\n' { '\n' } else { ' ' }),
);
rest = &rest[end..];
}
out.push_str(rest);
out
}
fn blank_script_comments(js: &str) -> String {
let b = js.as_bytes();
let mut out = b.to_vec();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < b.len() {
if let Some(q) = quote {
if b[i] == b'\\' {
i += 2;
continue;
}
if b[i] == q {
quote = None;
}
i += 1;
continue;
}
match b[i] {
b'\'' | b'"' | b'`' => quote = Some(b[i]),
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
out[i] = b' ';
i += 1;
}
continue;
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let end = js[i + 2..].find("*/").map_or(b.len(), |e| i + 2 + e + 2);
for o in &mut out[i..end] {
if *o != b'\n' {
*o = b' ';
}
}
i = end;
continue;
}
_ => {}
}
i += 1;
}
String::from_utf8(out).unwrap_or_default()
}
fn string_literals(js: &str) -> Vec<(usize, &str)> {
let b = js.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if matches!(b[i], b'\'' | b'"' | b'`') {
let q = b[i];
let mut j = i + 1;
while j < b.len() && b[j] != q {
j += if b[j] == b'\\' { 2 } else { 1 };
}
let end = j.min(b.len());
out.push((i, &js[i + 1..end]));
i = end + 1;
} else {
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markup_that_writes_a_look_is_found() {
let html = "<h1 style=\"color: #d33;\">Authorization Failed</h1>\n\
<div class=\"chart-bar\" style=\"--value:3;--most:9\"></div>\n\
<style>\n.a { color: red; }\n</style>\n\
<!-- <p style=\"color: red\"></p> -->\n\
<svg><path fill=\"#808080\" d=\"M0 0\"/><path fill=\"currentColor\"/><path stroke=\"var(--border)\"/></svg>\n\
<span style=\"{{ dynamic }}\"></span>\n";
let found: Vec<(usize, Rule)> = markup(html).iter().map(|f| (f.line, f.rule)).collect();
assert_eq!(
found,
vec![
(1, Rule::InlineStyle),
(3, Rule::StyleBlock),
(7, Rule::SvgPaint)
]
);
}
#[test]
fn a_script_that_writes_a_look_is_found() {
let js = "panel.style.display = 'none';\n\
fill.style.width = pct + '%';\n\
el.style.setProperty('--fill', pct + '%');\n\
if (el.style.display == 'none') {}\n\
const w = el.style.width;\n\
el.innerHTML = '<b style=\"color: red\">x</b>';\n\
el.setAttribute('style', 'color: red');\n\
// el.style.color = 'red';\n\
el.style.setProperty('color', 'red');\n\
el.style.cssText = 'color: red';\n";
let lines: Vec<usize> = script(js).iter().map(|f| f.line).collect();
assert_eq!(lines, vec![1, 2, 6, 7, 9, 10]);
}
fn rules(css_src: &str, ctx: &Context) -> Vec<(usize, Rule)> {
css(css_src, ctx).iter().map(|f| (f.line, f.rule)).collect()
}
fn only(css_src: &str, rule: Rule) -> Vec<usize> {
css(css_src, &Context::default())
.iter()
.filter(|f| f.rule == rule)
.map(|f| f.line)
.collect()
}
#[test]
fn ids_round_trip() {
for &r in Rule::ALL {
assert_eq!(Rule::from_id(r.id()), Some(r));
}
}
#[test]
fn the_colour_rule_is_colour_literals() {
let src = ".a { color: #fff; }\n.b { color: var(--content); }\n";
assert_eq!(only(src, Rule::Colour), vec![1]);
assert_eq!(crate::colour_literals(src).len(), 1);
}
#[test]
fn opacity_as_an_ink_is_found_and_a_fade_is_not() {
let src = "button.secondary:hover { opacity: 0.8; }\n\
input::placeholder { opacity: .5; }\n\
.hidden { opacity: 0; }\n\
.shown { opacity: 1; }\n\
@keyframes toast-in { from { opacity: 0.2; } to { opacity: 1; } }\n\
.half { opacity: 50%; }\n";
assert_eq!(only(src, Rule::OpacityInk), vec![1, 2, 6]);
}
#[test]
fn a_status_tint_mixed_by_hand_is_found() {
let src = ".banner--warning { background: color-mix(in srgb, var(--warning) 14%, var(--surface-page)); }\n\
.inv:hover { background: color-mix(in oklch, var(--content) 85%, var(--surface-page)); }\n\
.ok { background: var(--warning-surface); color: var(--warning-text); }\n";
assert_eq!(only(src, Rule::MixedTint), vec![1]);
}
#[test]
fn a_literal_length_the_scale_owns_is_found() {
let src = ".a { border: 1px solid var(--border); }\n\
.b { padding: 6px 12px; }\n\
.c { max-width: 1200px; }\n\
.d { box-shadow: 0 0 0 2px var(--danger); }\n\
.e { border: var(--stroke-hairline) solid var(--border); margin: 0; }\n\
.f { padding: var(--gap-group) calc(2 * var(--step-snug)); }\n\
.g { width: 300px; line-height: 1.5; }\n\
.h { gap: var(--chart-gap, 2px); max-width: 100%; }\n\
.i { font-size: 0.875em; }\n\
.j { max-width: 100vw; padding-block: 1lh; }\n";
assert_eq!(only(src, Rule::Length), vec![1, 2, 3, 4, 9]);
}
#[test]
fn a_literal_duration_is_found() {
let src = ".a { transition: opacity 0.2s ease; }\n\
.b { animation: toast-in 0.3s ease-out; }\n\
.c { transition: top var(--motion-fade) ease; }\n\
.d { transition-duration: 0s; }\n\
.e { transition: transform var(--timing-swift, 120ms) ease; }\n";
assert_eq!(only(src, Rule::Duration), vec![1, 2]);
}
#[test]
fn a_literal_face_is_found() {
let src = ".a { font-family: system-ui, -apple-system, sans-serif; }\n\
.b { font-family: var(--font-sans); }\n\
.c { font-family: inherit; }\n";
assert_eq!(only(src, Rule::Face), vec![1]);
}
#[test]
fn restating_a_generated_token_is_a_shadow() {
let ctx = Context::generated(&[":root { --stroke-hairline: 1px; --content: #000; }"]);
let src = ":root { --content: #111; --border-width: 1px; }\n";
let found = rules(src, &ctx);
assert!(found.contains(&(1, Rule::TokenShadow)), "{found:?}");
assert_eq!(
found
.iter()
.filter(|(_, r)| *r == Rule::TokenShadow)
.count(),
1,
"{found:?}"
);
}
#[test]
fn a_token_nothing_defines_is_found() {
let mut ctx = Context::generated(&[":root { --content: #000; }"]);
ctx.set_at_runtime(["--value"]);
let src = ":root { --local: 1rem; }\n\
.a { color: var(--contnet); }\n\
.b { color: var(--content); padding: var(--local); }\n\
.c { z-index: var(--z-overlay, 900); }\n\
.d { height: calc(var(--value) * 1%); }\n";
assert_eq!(
css(src, &ctx)
.iter()
.filter(|f| f.rule == Rule::UndefinedVar)
.map(|f| f.line)
.collect::<Vec<_>>(),
vec![2]
);
}
#[test]
fn selectors_at_rules_and_comments_are_not_declarations() {
let src = "/* padding: 6px; opacity: .5 */\n\
@media (hover: hover) and (pointer: fine) {\n\
.a:hover { color: var(--content); }\n\
}\n\
@layer reset, base;\n";
let ctx = Context::generated(&[":root { --content: #000; }"]);
assert_eq!(css(src, &ctx), vec![]);
}
#[test]
fn one_declaration_can_break_two_rules() {
let src = ".toast { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }\n";
assert_eq!(
rules(src, &Context::default()),
vec![(1, Rule::Colour), (1, Rule::Length)]
);
}
}