use crate::html::find_tag_end;
pub(crate) fn extract_all_style_blocks(html: &str) -> Vec<String> {
let mut blocks = Vec::new();
let lower = html.to_ascii_lowercase();
let mut cursor = 0;
while let Some(rel_open) = lower[cursor..].find("<style") {
let abs_open = cursor + rel_open;
let tag_end = find_tag_end(&lower, abs_open);
cursor = tag_end;
let Some(rel_close) = lower[cursor..].find("</style>") else {
break;
};
blocks.push(html[cursor..cursor + rel_close].to_string());
cursor += rel_close + "</style>".len();
}
blocks
}
pub(crate) fn preprocess_css(css: &str) -> String {
let lower = css.to_ascii_lowercase();
let no_comments = strip_css_comments(&lower);
strip_at_rules(&no_comments)
}
pub(crate) fn strip_css_comments(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && &bytes[i..i + 2] == b"/*" {
i += 2;
while i + 1 < bytes.len() && &bytes[i..i + 2] != b"*/" {
i += 1;
}
i = (i + 2).min(bytes.len());
out.push(' ');
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
pub(crate) fn strip_at_rules(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'@' {
let mut j = i;
while j < bytes.len() && bytes[j] != b'{' && bytes[j] != b';' {
j += 1;
}
if j >= bytes.len() {
break;
}
if bytes[j] == b';' {
i = j + 1;
continue;
}
let mut depth = 0_i32;
let mut k = j;
while k < bytes.len() {
match bytes[k] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
k += 1;
break;
}
}
_ => {}
}
k += 1;
}
i = k;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
pub(crate) fn parse_top_level_rules(css: &str) -> Vec<(String, String)> {
let mut rules = Vec::new();
let bytes = css.as_bytes();
let mut i = 0;
while i < bytes.len() {
let Some(open_rel) = css[i..].find('{') else {
break;
};
let open = i + open_rel;
let selector = css[i..open].trim().to_string();
if selector.is_empty() {
i = open + 1;
continue;
}
let mut depth = 1_i32;
let mut j = open + 1;
while j < bytes.len() {
match bytes[j] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
let body = css[open + 1..j].to_string();
rules.push((selector, body));
i = j + 1;
}
rules
}
pub(crate) fn selector_targets_interactive(selector: &str) -> bool {
selector.contains("button")
|| selector.contains("input")
|| selector.contains("[role=\"button\"]")
|| selector.contains("[role='button']")
|| selector.contains("[role=button]")
|| selector == "a"
|| selector.starts_with("a ")
|| selector.starts_with("a:")
|| selector.starts_with("a.")
|| selector.starts_with("a#")
|| selector.starts_with("a[")
}
pub(crate) fn first_px_value(css: &str, prop: &str) -> Option<u32> {
let pat = format!("{prop}:");
let start = css.find(&pat)?;
let after = &css[start + pat.len()..];
let value = after.split(';').next()?.trim();
let digits: String =
value.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
if value[digits.len()..].trim_start().starts_with("px") {
digits.parse().ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_px_value_returns_none_for_non_numeric_value() {
assert_eq!(first_px_value("width:auto;", "width"), None);
}
#[test]
fn first_px_value_returns_none_for_non_px_unit() {
assert_eq!(first_px_value("width:10em;", "width"), None);
}
#[test]
fn extract_all_style_blocks_ignores_unterminated_block() {
let html = "<html><head><style>button{width:8px}";
assert!(extract_all_style_blocks(html).is_empty());
}
#[test]
fn strip_at_rules_removes_bare_at_rule_with_semicolon() {
let out = strip_at_rules("@import url(x.css);a{color:red}");
assert!(!out.contains("@import"), "got: {out}");
assert!(out.contains("a{color:red}"));
}
#[test]
fn strip_at_rules_stops_at_unterminated_preamble() {
let out = strip_at_rules("a{x:y}@media (min-width: 600px");
assert!(out.contains("a{x:y}"));
assert!(!out.contains("@media"));
}
#[test]
fn parse_top_level_rules_handles_nested_braces_in_body() {
let rules = parse_top_level_rules("s{a{b}c}");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].0, "s");
assert_eq!(rules[0].1, "a{b}c");
}
#[test]
fn parse_top_level_rules_skips_empty_selector() {
let rules = parse_top_level_rules("{ width: 10px; }");
assert!(rules.is_empty());
}
#[test]
fn strip_at_rules_handles_nested_media() {
let css = "a { color: red } @media print { a { color: blue } } b { color: green }";
let stripped = strip_at_rules(css);
assert!(stripped.contains("a { color: red }"));
assert!(stripped.contains("b { color: green }"));
assert!(!stripped.contains("@media"));
assert!(!stripped.contains("blue"));
}
#[test]
fn strip_css_comments_removes_block_comments() {
let css = "a { /* hidden */ color: red; }";
let stripped = strip_css_comments(css);
assert!(!stripped.contains("hidden"));
assert!(stripped.contains("color: red"));
}
#[test]
fn strip_css_comments_handles_unterminated_comment() {
let css = "a { /* never closes";
let _ = strip_css_comments(css);
}
#[test]
fn extract_all_style_blocks_returns_every_block() {
let html =
"<html><head><style>x{}</style><style>y{}</style></head></html>";
let blocks = extract_all_style_blocks(html);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].trim(), "x{}");
assert_eq!(blocks[1].trim(), "y{}");
}
#[test]
fn extract_all_style_blocks_handles_attributes_with_quoted_gt() {
let html =
r#"<html><head><style data-tag="x>y">a{}</style></head></html>"#;
let blocks = extract_all_style_blocks(html);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].trim(), "a{}");
}
}