use std::collections::BTreeSet;
use std::ops::Range;
use crate::literals::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,
Weight,
Leading,
Tracking,
Layer,
}
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,
Self::Weight,
Self::Leading,
Self::Tracking,
Self::Layer,
];
#[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",
Self::Weight => "weight",
Self::Leading => "leading",
Self::Tracking => "tracking",
Self::Layer => "layer",
}
}
#[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_declarations(sheet)) {
ctx.generated.insert(name.clone());
ctx.defined.insert(name);
}
}
ctx
}
pub fn link(&mut self, sheet: &str) {
self.defined
.extend(custom_properties(&sheet_declarations(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 decls = sheet_declarations(css);
let mut own: BTreeSet<String> = ctx.defined.clone();
own.extend(custom_properties(&decls));
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(") && vars(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 vars(v).any(|(n, fallback)| !fallback && !defined.contains(n)) {
hit.push(Rule::UndefinedVar);
}
let literal = !is_token_or_keyword(v);
if p == "font-weight" && literal && !v.eq_ignore_ascii_case("normal") {
hit.push(Rule::Weight);
}
if p == "line-height" && literal && !v.eq_ignore_ascii_case("normal") {
hit.push(Rule::Leading);
}
if p == "letter-spacing" && literal && !matches!(v.trim(), "0" | "normal") {
hit.push(Rule::Tracking);
}
if p == "z-index" && literal && !matches!(v.trim(), "0" | "1" | "-1" | "auto") {
hit.push(Rule::Layer);
}
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}-")))
}
pub(crate) struct Decl {
pub(crate) line: usize,
pub(crate) property: String,
pub(crate) value: String,
pub(crate) text: String,
in_keyframes: bool,
}
pub(crate) fn sheet_declarations(css: &str) -> Vec<Decl> {
let (text, _) = blank(css, Lang::Css);
let mut out = Vec::new();
let mut preludes: Vec<String> = Vec::new();
let mut start = 0;
let in_keyframes = |preludes: &[String]| preludes.iter().any(|p| p.contains("keyframes"));
for (at, c) in text.char_indices() {
match c {
'{' => {
preludes.push(text[start..at].trim().to_ascii_lowercase());
start = at + 1;
}
';' | '}' => {
out.extend(declaration(&text, start..at, in_keyframes(&preludes)));
if c == '}' {
preludes.pop();
}
start = at + 1;
}
_ => {}
}
}
out.extend(declaration(
&text,
start..text.len(),
in_keyframes(&preludes),
));
out
}
fn declaration(text: &str, span: Range<usize>, in_keyframes: bool) -> Option<Decl> {
let segment = &text[span.clone()];
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: line_at(text, span.start + lead),
property,
value: segment[colon + 1..].trim().to_string(),
text: one_line(segment),
in_keyframes,
})
}
fn line_at(text: &str, at: usize) -> usize {
text[..at].matches('\n').count() + 1
}
fn one_line(quote: &str) -> String {
quote.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn custom_properties(decls: &[Decl]) -> impl Iterator<Item = String> {
decls
.iter()
.filter_map(|d| d.property.strip_prefix("--").map(str::to_string))
}
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 vars(value: &str) -> impl Iterator<Item = (&str, bool)> {
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());
let fallback = !rest[len..].trim_start().starts_with(')');
Some((&rest[..len], fallback))
})
}
#[must_use]
pub fn markup(html: &str) -> Vec<Finding> {
let (text, _) = blank(html, Lang::Markup);
let lower = text.to_ascii_lowercase();
let mut found = Vec::new();
let mut push = |rule, at: usize, quote: &str| {
found.push(Finding {
rule,
line: line_at(&text, at),
declaration: one_line(quote),
});
};
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, strings) = blank(js, Lang::Script);
let mut found = Vec::new();
let line_of = |at: usize| line_at(&text, at);
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, span) in strings {
let lit = &text[span];
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("{{")
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Lang {
Css,
Script,
Markup,
}
impl Lang {
fn comments(self) -> &'static [(&'static str, &'static str)] {
match self {
Self::Css => &[("/*", "*/")],
Self::Script => &[("/*", "*/"), ("//", "\n")],
Self::Markup => &[("<!--", "-->"), ("{#", "#}")],
}
}
fn quotes(self) -> &'static [u8] {
match self {
Self::Css => b"\"'",
Self::Script => b"\"'`",
Self::Markup => b"",
}
}
}
fn blank(src: &str, lang: Lang) -> (String, Vec<(usize, Range<usize>)>) {
let b = src.as_bytes();
let mut out = b.to_vec();
let mut strings = Vec::new();
let find = |from: usize, close: &str| {
b[from..]
.windows(close.len())
.position(|w| w == close.as_bytes())
.map(|e| from + e)
};
let mut i = 0;
while i < b.len() {
let comment = lang
.comments()
.iter()
.find(|(open, _)| b[i..].starts_with(open.as_bytes()));
let end = if let Some((open, close)) = comment {
find(i + open.len(), close).map_or(b.len(), |e| e + close.len())
} else if lang.quotes().contains(&b[i]) {
let mut j = i + 1;
while j < b.len() && b[j] != b[i] {
j += if b[j] == b'\\' { 2 } else { 1 };
}
strings.push((i, i + 1..j.min(b.len())));
let end = (j + 1).min(b.len());
if lang != Lang::Css {
i = end;
continue;
}
end
} else if lang == Lang::Css
&& b[i..].len() >= 4
&& b[i..i + 4].eq_ignore_ascii_case(b"url(")
{
find(i, ")").map_or(b.len(), |e| e + 1)
} else {
i += 1;
continue;
};
for o in &mut out[i..end] {
if *o != b'\n' {
*o = b' ';
}
}
i = end;
}
(String::from_utf8(out).unwrap_or_default(), strings)
}
#[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 a_last_declaration_without_a_terminator_is_read() {
assert_eq!(only("color: #fff", Rule::Colour), vec![1]);
assert_eq!(only(".a {\n padding: 6px", Rule::Length), vec![2]);
assert_eq!(crate::colour_literals("color: #fff").len(), 1);
}
#[test]
fn an_at_rule_is_not_a_declaration() {
assert_eq!(crate::colour_literals("@x:#fff;"), Vec::new());
}
#[test]
fn strings_and_comments_hide_each_other() {
let js = "const u = 'http://x'; el.style.color = 'red';\n\
/* it's */ el.style.width = '1px';\n";
assert_eq!(
script(js).iter().map(|f| f.line).collect::<Vec<_>>(),
vec![1, 2]
);
let html = "{# <p style=\"color: red\"> #}\n<p style=\"color: red\">é</p>\n";
assert_eq!(
markup(html).iter().map(|f| f.line).collect::<Vec<_>>(),
vec![2]
);
}
#[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 a_type_or_layer_value_off_the_roles_is_a_finding() {
let src = "a { font-weight: 700; }\n\
b { font-weight: var(--weight-strong); }\n\
c { font-weight: normal; line-height: 1.4; }\n\
d { line-height: var(--leading-tight); letter-spacing: 0.02em; }\n\
e { letter-spacing: 0; z-index: 900; }\n\
f { z-index: 1; z-index: var(--layer-overlay); z-index: auto; }\n";
assert_eq!(only(src, Rule::Weight), vec![1]);
assert_eq!(only(src, Rule::Leading), vec![3]);
assert_eq!(only(src, Rule::Tracking), vec![4]);
assert_eq!(only(src, Rule::Layer), vec![5]);
}
#[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)]
);
}
}