feat(html): declaration parsing, selector subset and cascade with ua defaults

This commit is contained in:
Kayshen-X 2026-07-18 14:16:42 +08:00
parent c9e06817dc
commit 623e503617
4 changed files with 819 additions and 0 deletions

View file

@ -0,0 +1,317 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::css::declarations::{parse_declarations, Declaration};
use crate::css::selectors::{matches, parse_selector_list, specificity, Selector};
use crate::dom::DomElement;
use crate::length::{parse_length, CssLength, LengthCtx};
pub const UA_STYLESHEET: &str = "\
body{font-size:16px;color:#111111}\
h1{font-size:32px;font-weight:700;margin:21px 0}\
h2{font-size:24px;font-weight:700;margin:20px 0}\
h3{font-size:19px;font-weight:700;margin:18px 0}\
h4{font-size:16px;font-weight:700;margin:21px 0}\
h5{font-size:13px;font-weight:700;margin:22px 0}\
h6{font-size:11px;font-weight:700;margin:24px 0}\
p{margin:16px 0}\
ul,ol{margin:16px 0;padding:0 0 0 40px}\
b,strong{font-weight:700}\
i,em{font-style:italic}\
u{text-decoration:underline}\
s,del,strike{text-decoration:line-through}\
a{color:#0066cc;text-decoration:underline}\
code,pre{font-family:monospace}\
hr{margin:8px 0}";
#[derive(Clone, Debug)]
pub struct StyleRule {
pub selector: Selector,
pub declarations: Vec<Declaration>,
pub order: usize,
}
#[derive(Clone, Debug)]
pub struct ComputedStyle {
pub props: BTreeMap<String, String>,
pub font_size: f64,
}
impl ComputedStyle {
pub fn get(&self, name: &str) -> Option<&str> {
self.props.get(name).map(String::as_str)
}
}
pub fn parse_stylesheet(css: &str, first_order: usize) -> (Vec<StyleRule>, Vec<String>) {
let css = strip_comments(css);
let mut rules = Vec::new();
let mut warnings = Vec::new();
let mut warned_at_rules = BTreeSet::new();
let mut index = 0;
let mut order = first_order;
while index < css.len() {
index = skip_whitespace(&css, index);
if index >= css.len() {
break;
}
if css.as_bytes()[index] == b'@' {
let name_end = css[index + 1..]
.find(|ch: char| ch.is_whitespace() || ch == '{' || ch == ';')
.map_or(css.len(), |offset| index + 1 + offset);
let name = css[index + 1..name_end].to_ascii_lowercase();
if warned_at_rules.insert(name.clone()) {
warnings.push(at_rule_warning(&name));
}
let next_brace = css[index..].find('{').map(|offset| index + offset);
let next_semicolon = css[index..].find(';').map(|offset| index + offset);
index = match (next_brace, next_semicolon) {
(Some(brace), Some(semicolon)) if semicolon < brace => semicolon + 1,
(Some(brace), _) => matching_brace(&css, brace).map_or(css.len(), |end| end + 1),
(_, Some(semicolon)) => semicolon + 1,
_ => css.len(),
};
continue;
}
let Some(open_offset) = css[index..].find('{') else {
break;
};
let open = index + open_offset;
let Some(close) = matching_brace(&css, open) else {
break;
};
let selectors = parse_selector_list(css[index..open].trim());
let declarations = parse_declarations(&css[open + 1..close]);
for selector in selectors {
rules.push(StyleRule {
selector,
declarations: declarations.clone(),
order,
});
order = order.saturating_add(1);
}
index = close + 1;
}
(rules, warnings)
}
fn strip_comments(css: &str) -> String {
let mut stripped = String::with_capacity(css.len());
let mut rest = css;
while let Some(start) = rest.find("/*") {
stripped.push_str(&rest[..start]);
let after_start = &rest[start + 2..];
if let Some(end) = after_start.find("*/") {
rest = &after_start[end + 2..];
} else {
rest = "";
}
}
stripped.push_str(rest);
stripped
}
fn skip_whitespace(css: &str, mut index: usize) -> usize {
while index < css.len() && css.as_bytes()[index].is_ascii_whitespace() {
index += 1;
}
index
}
fn matching_brace(css: &str, open: usize) -> Option<usize> {
let mut depth = 0u32;
let mut quote = None;
for (offset, ch) in css[open..].char_indices() {
if let Some(active) = quote {
if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'{' => depth += 1,
'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(open + offset);
}
}
_ => {}
}
}
None
}
fn at_rule_warning(name: &str) -> String {
if name == "media" {
"@media rules ignored (import viewport applies)".to_string()
} else {
format!("@{name} rules ignored")
}
}
pub fn compute_style(
path: &[&DomElement],
rules: &[StyleRule],
parent: Option<&ComputedStyle>,
root_font_size: f64,
) -> ComputedStyle {
let mut matching_rules: Vec<_> = rules
.iter()
.filter(|rule| matches(&rule.selector, path))
.map(|rule| (specificity(&rule.selector), rule.order, rule))
.collect();
matching_rules.sort_by_key(|(specificity, order, _)| (*specificity, *order));
let inline = path
.last()
.and_then(|element| element.attr("style"))
.map(parse_declarations)
.unwrap_or_default();
let mut props = BTreeMap::new();
apply_rule_declarations(&mut props, &matching_rules, false);
apply_declarations(&mut props, &inline, false);
apply_rule_declarations(&mut props, &matching_rules, true);
apply_declarations(&mut props, &inline, true);
let declared_font_size = props.get("font-size").cloned();
if let Some(parent) = parent {
for &property in INHERITED_PROPERTIES {
if !props.contains_key(property) {
if let Some(value) = parent.props.get(property) {
props.insert(property.to_string(), value.clone());
}
}
}
}
let inherited_size = parent.map_or(root_font_size, |style| style.font_size);
let font_size = declared_font_size
.as_deref()
.and_then(|value| resolve_font_size(value, inherited_size, root_font_size))
.unwrap_or(inherited_size);
ComputedStyle { props, font_size }
}
const INHERITED_PROPERTIES: &[&str] = &[
"color",
"font-family",
"font-size",
"font-weight",
"font-style",
"line-height",
"letter-spacing",
"text-align",
];
fn apply_rule_declarations(
props: &mut BTreeMap<String, String>,
rules: &[((u32, u32, u32), usize, &StyleRule)],
important: bool,
) {
for (_, _, rule) in rules {
apply_declarations(props, &rule.declarations, important);
}
}
fn apply_declarations(
props: &mut BTreeMap<String, String>,
declarations: &[Declaration],
important: bool,
) {
for declaration in declarations
.iter()
.filter(|declaration| declaration.important == important)
{
props.insert(declaration.name.clone(), declaration.value.clone());
}
}
fn resolve_font_size(value: &str, parent_size: f64, root_size: f64) -> Option<f64> {
let context = LengthCtx {
font_size: parent_size,
root_font_size: root_size,
viewport_w: 0.0,
viewport_h: 0.0,
};
match parse_length(value, &context)? {
CssLength::Px(value) => Some(value),
CssLength::Percent(percent) => Some(parent_size * percent / 100.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::DomElement;
fn el(tag: &str, class: &str, style: &str) -> DomElement {
let mut attrs = Vec::new();
if !class.is_empty() {
attrs.push(("class".into(), class.into()));
}
if !style.is_empty() {
attrs.push(("style".into(), style.into()));
}
DomElement {
tag: tag.into(),
attrs,
children: Vec::new(),
}
}
#[test]
fn specificity_and_order_win() {
let (rules, _) = parse_stylesheet(
"p { color: #111111 } .hot { color: #ff0000 } p { margin-top: 4px }",
100,
);
let paragraph = el("p", "hot", "");
let computed = compute_style(&[&paragraph], &rules, None, 16.0);
assert_eq!(computed.get("color"), Some("#ff0000"));
assert_eq!(computed.get("margin-top"), Some("4px"));
}
#[test]
fn inline_beats_rules_but_important_beats_inline() {
let (rules, _) = parse_stylesheet(".a { color: #00ff00 !important }", 100);
let div = el("div", "a", "color: #0000ff");
let computed = compute_style(&[&div], &rules, None, 16.0);
assert_eq!(computed.get("color"), Some("#00ff00"));
}
#[test]
fn inheritance_and_font_size_units() {
let (rules, _) = parse_stylesheet("div { color: #333333; font-size: 20px }", 100);
let parent_element = el("div", "", "");
let parent = compute_style(&[&parent_element], &rules, None, 16.0);
assert_eq!(parent.font_size, 20.0);
let child_element = el("span", "", "font-size: 1.5em");
let child = compute_style(
&[&parent_element, &child_element],
&rules,
Some(&parent),
16.0,
);
assert_eq!(child.get("color"), Some("#333333"));
assert_eq!(child.font_size, 30.0);
}
#[test]
fn at_rules_skipped_with_warning() {
let (rules, warnings) = parse_stylesheet(
"@media (max-width:600px){ p{color:red} } p{color:#222222}",
0,
);
assert_eq!(rules.len(), 1);
assert_eq!(warnings.len(), 1);
}
#[test]
fn ua_defaults_apply() {
let (rules, _) = parse_stylesheet(UA_STYLESHEET, 0);
let heading = el("h1", "", "");
let computed = compute_style(&[&heading], &rules, None, 16.0);
assert_eq!(computed.font_size, 32.0);
assert_eq!(computed.get("font-weight"), Some("700"));
}
}

View file

@ -0,0 +1,317 @@
use crate::color::parse_css_color;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Declaration {
pub name: String,
pub value: String,
pub important: bool,
}
pub fn parse_declarations(block: &str) -> Vec<Declaration> {
let mut declarations = Vec::new();
for statement in split_top_level(block, ';') {
let Some((name, raw_value)) = statement.split_once(':') else {
continue;
};
let name = name.trim().to_ascii_lowercase();
let (value, important) = strip_important(raw_value);
if name.is_empty() || value.is_empty() {
continue;
}
match name.as_str() {
"margin" | "padding" => expand_box(&mut declarations, &name, value, important),
"border" => expand_border(&mut declarations, None, value, important),
"border-top" | "border-right" | "border-bottom" | "border-left" => expand_border(
&mut declarations,
name.strip_prefix("border-"),
value,
important,
),
"background" => expand_background(&mut declarations, value, important),
"font" => expand_font(&mut declarations, value, important),
"gap" => {
if let Some(first) = value.split_whitespace().next() {
push(&mut declarations, "gap", first, important);
}
}
"flex" => {
if let Some(first) = value.split_whitespace().next() {
if first.parse::<f64>().is_ok() {
push(&mut declarations, "flex-grow", first, important);
}
}
}
"text-decoration" => {
if value.split_whitespace().any(|part| part == "underline") {
push(
&mut declarations,
"text-decoration-line",
"underline",
important,
);
}
if value.split_whitespace().any(|part| part == "line-through") {
push(
&mut declarations,
"text-decoration-line",
"line-through",
important,
);
}
}
_ => push(&mut declarations, &name, value, important),
}
}
declarations
}
fn split_top_level(input: &str, delimiter: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut depth = 0u32;
let mut quote = None;
for (index, ch) in input.char_indices() {
if let Some(active) = quote {
if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ if ch == delimiter && depth == 0 => {
parts.push(input[start..index].trim());
start = index + ch.len_utf8();
}
_ => {}
}
}
parts.push(input[start..].trim());
parts
}
fn strip_important(value: &str) -> (&str, bool) {
let trimmed = value.trim();
let lower = trimmed.to_ascii_lowercase();
if lower.ends_with("!important") {
let cutoff = trimmed.len() - "!important".len();
(trimmed[..cutoff].trim_end(), true)
} else {
(trimmed, false)
}
}
fn push(out: &mut Vec<Declaration>, name: &str, value: &str, important: bool) {
out.push(Declaration {
name: name.to_string(),
value: value.trim().to_string(),
important,
});
}
fn expand_box(out: &mut Vec<Declaration>, name: &str, value: &str, important: bool) {
let values: Vec<_> = value.split_whitespace().collect();
let sides = match values.as_slice() {
[all] => [*all, *all, *all, *all],
[vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal],
[top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal],
[top, right, bottom, left] => [*top, *right, *bottom, *left],
_ => return,
};
for (side, value) in ["top", "right", "bottom", "left"].into_iter().zip(sides) {
push(out, &format!("{name}-{side}"), value, important);
}
}
fn expand_border(out: &mut Vec<Declaration>, side: Option<&str>, value: &str, important: bool) {
let mut width = None;
let mut style = None;
let mut color = None;
for token in value.split_whitespace() {
if parse_css_color(token).is_some() {
color = Some(token);
} else if is_border_width(token) {
width = Some(token);
} else if matches!(
token,
"none"
| "hidden"
| "dotted"
| "dashed"
| "solid"
| "double"
| "groove"
| "ridge"
| "inset"
| "outset"
) {
style = Some(token);
}
}
let prefix = side.map_or_else(|| "border".to_string(), |side| format!("border-{side}"));
if let Some(width) = width {
push(out, &format!("{prefix}-width"), width, important);
}
if side.is_none() {
if let Some(style) = style {
push(out, "border-style", style, important);
}
}
if let Some(color) = color {
push(out, &format!("{prefix}-color"), color, important);
}
}
fn is_border_width(token: &str) -> bool {
matches!(token, "thin" | "medium" | "thick" | "0")
|| ["px", "em", "rem", "pt"].iter().any(|unit| {
token
.strip_suffix(unit)
.is_some_and(|n| n.parse::<f64>().is_ok())
})
}
fn expand_background(out: &mut Vec<Declaration>, value: &str, important: bool) {
if value.contains("-gradient(") {
push(out, "background-image", value, important);
return;
}
if let Some(image) = extract_function(value, "url(") {
push(out, "background-image", image, important);
}
if parse_css_color(value).is_some() {
push(out, "background-color", value, important);
return;
}
if let Some(color) = value
.split_whitespace()
.find(|token| parse_css_color(token).is_some())
{
push(out, "background-color", color, important);
}
}
fn extract_function<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
let start = value.find(prefix)?;
let mut depth = 0u32;
for (offset, ch) in value[start..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(&value[start..start + offset + 1]);
}
}
_ => {}
}
}
None
}
fn expand_font(out: &mut Vec<Declaration>, value: &str, important: bool) {
let tokens: Vec<_> = value.split_whitespace().collect();
let Some(size_index) = tokens.iter().position(|token| is_font_size_token(token)) else {
return;
};
for token in &tokens[..size_index] {
if *token == "italic" || *token == "oblique" {
push(out, "font-style", token, important);
} else if *token == "bold"
|| token
.parse::<u16>()
.is_ok_and(|weight| (100..=900).contains(&weight))
{
push(out, "font-weight", token, important);
}
}
let (size, line_height) = tokens[size_index]
.split_once('/')
.map_or((tokens[size_index], None), |(size, height)| {
(size, Some(height))
});
push(out, "font-size", size, important);
if let Some(line_height) = line_height {
push(out, "line-height", line_height, important);
}
if size_index + 1 < tokens.len() {
push(
out,
"font-family",
&tokens[size_index + 1..].join(" "),
important,
);
}
}
fn is_font_size_token(token: &str) -> bool {
let size = token.split('/').next().unwrap_or(token);
["px", "em", "rem", "pt", "%"].iter().any(|unit| {
size.strip_suffix(unit)
.is_some_and(|n| n.parse::<f64>().is_ok())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn get<'a>(declarations: &'a [Declaration], name: &str) -> Option<&'a str> {
declarations
.iter()
.rev()
.find(|declaration| declaration.name == name)
.map(|declaration| declaration.value.as_str())
}
#[test]
fn margin_shorthand_expands() {
let declarations = parse_declarations("margin: 10px 20px");
assert_eq!(get(&declarations, "margin-top"), Some("10px"));
assert_eq!(get(&declarations, "margin-right"), Some("20px"));
assert_eq!(get(&declarations, "margin-bottom"), Some("10px"));
assert_eq!(get(&declarations, "margin-left"), Some("20px"));
}
#[test]
fn border_and_background() {
let declarations =
parse_declarations("border: 1px solid #000; background: #fff url(x.png)");
assert_eq!(get(&declarations, "border-width"), Some("1px"));
assert_eq!(get(&declarations, "border-color"), Some("#000"));
assert_eq!(get(&declarations, "background-image"), Some("url(x.png)"));
assert_eq!(get(&declarations, "background-color"), Some("#fff"));
}
#[test]
fn important_flag_and_case() {
let declarations = parse_declarations("COLOR: red !important");
assert_eq!(declarations[0].name, "color");
assert_eq!(declarations[0].value, "red");
assert!(declarations[0].important);
}
#[test]
fn gradient_goes_to_background_image() {
let declarations = parse_declarations("background: linear-gradient(90deg, #000, #fff)");
assert_eq!(
get(&declarations, "background-image"),
Some("linear-gradient(90deg, #000, #fff)")
);
assert_eq!(get(&declarations, "background-color"), None);
}
#[test]
fn flex_and_font() {
let declarations =
parse_declarations("flex: 1; font: italic 700 18px/1.4 Inter, sans-serif");
assert_eq!(get(&declarations, "flex-grow"), Some("1"));
assert_eq!(get(&declarations, "font-style"), Some("italic"));
assert_eq!(get(&declarations, "font-weight"), Some("700"));
assert_eq!(get(&declarations, "font-size"), Some("18px"));
assert_eq!(get(&declarations, "line-height"), Some("1.4"));
assert_eq!(get(&declarations, "font-family"), Some("Inter, sans-serif"));
}
}

View file

@ -0,0 +1,3 @@
pub mod cascade;
pub mod declarations;
pub mod selectors;

View file

@ -0,0 +1,182 @@
use crate::dom::DomElement;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompoundSelector {
pub tag: Option<String>,
pub id: Option<String>,
pub classes: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Selector {
pub compounds: Vec<CompoundSelector>,
}
pub fn parse_selector_list(input: &str) -> Vec<Selector> {
input
.split(',')
.filter_map(|candidate| {
let candidate = candidate.trim();
if candidate.is_empty()
|| candidate
.chars()
.any(|ch| matches!(ch, '>' | '+' | '~' | '[' | ':' | '*'))
{
return None;
}
let compounds: Option<Vec<_>> =
candidate.split_whitespace().map(parse_compound).collect();
let compounds = compounds?;
(!compounds.is_empty()).then_some(Selector { compounds })
})
.collect()
}
fn parse_compound(input: &str) -> Option<CompoundSelector> {
let bytes = input.as_bytes();
let mut index = 0;
let mut tag = None;
let mut id = None;
let mut classes = Vec::new();
if !matches!(bytes.first(), Some(b'.' | b'#')) {
let end = input.find(['.', '#']).unwrap_or(input.len());
if end == 0 {
return None;
}
tag = Some(input[..end].to_ascii_lowercase());
index = end;
}
while index < input.len() {
let marker = bytes[index];
if marker != b'.' && marker != b'#' {
return None;
}
let start = index + 1;
let relative_end = input[start..]
.find(['.', '#'])
.unwrap_or(input.len() - start);
let end = start + relative_end;
if start == end {
return None;
}
let token = input[start..end].to_string();
if marker == b'#' {
if id.replace(token).is_some() {
return None;
}
} else {
classes.push(token);
}
index = end;
}
Some(CompoundSelector { tag, id, classes })
}
pub fn specificity(selector: &Selector) -> (u32, u32, u32) {
selector
.compounds
.iter()
.fold((0, 0, 0), |(ids, classes, tags), compound| {
(
ids + u32::from(compound.id.is_some()),
classes + compound.classes.len() as u32,
tags + u32::from(compound.tag.is_some()),
)
})
}
pub fn matches(selector: &Selector, path: &[&DomElement]) -> bool {
let Some((target, ancestors)) = path.split_last() else {
return false;
};
let Some((target_selector, earlier_selectors)) = selector.compounds.split_last() else {
return false;
};
if !compound_matches(target_selector, target) {
return false;
}
let mut ancestor_end = ancestors.len();
for compound in earlier_selectors.iter().rev() {
let Some(index) = ancestors[..ancestor_end]
.iter()
.rposition(|element| compound_matches(compound, element))
else {
return false;
};
ancestor_end = index;
}
true
}
fn compound_matches(selector: &CompoundSelector, element: &DomElement) -> bool {
if selector
.tag
.as_deref()
.is_some_and(|tag| tag != element.tag)
{
return false;
}
if selector
.id
.as_deref()
.is_some_and(|id| Some(id) != element.id())
{
return false;
}
let element_classes = element.classes();
selector
.classes
.iter()
.all(|class| element_classes.contains(&class.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::DomElement;
fn el(tag: &str, class: &str, id: &str) -> DomElement {
let mut attrs = Vec::new();
if !class.is_empty() {
attrs.push(("class".into(), class.into()));
}
if !id.is_empty() {
attrs.push(("id".into(), id.into()));
}
DomElement {
tag: tag.into(),
attrs,
children: Vec::new(),
}
}
#[test]
fn parses_and_scores() {
let selectors = parse_selector_list("div.card, #hero .title, p");
assert_eq!(selectors.len(), 3);
assert_eq!(specificity(&selectors[0]), (0, 1, 1));
assert_eq!(specificity(&selectors[1]), (1, 1, 0));
assert_eq!(specificity(&selectors[2]), (0, 0, 1));
}
#[test]
fn unsupported_selectors_are_dropped() {
let selectors = parse_selector_list("a:hover, div > p, .ok");
assert_eq!(selectors.len(), 1);
assert_eq!(selectors[0].compounds[0].classes, vec!["ok".to_string()]);
}
#[test]
fn descendant_matching() {
let hero = el("section", "", "hero");
let mid = el("div", "card", "");
let title = el("h2", "title", "");
let path: Vec<&DomElement> = vec![&hero, &mid, &title];
let selector = &parse_selector_list("#hero .title")[0];
assert!(matches(selector, &path));
let selector = &parse_selector_list("#hero .card .title")[0];
assert!(matches(selector, &path));
let selector = &parse_selector_list("#other .title")[0];
assert!(!matches(selector, &path));
}
}