Skip to main content

orinium_browser/engine/layouter/
css_resolver.rs

1//! A CSS resolver that handles selector matching and value resolution.
2
3use super::builder::resolve_css_len;
4use crate::engine::css::parser::{AtQuery, ComplexSelector, CssNode, CssNodeType, RangeOperator};
5use crate::engine::css::values::{CssIdent, CssValue};
6use crate::engine::layouter::types::ColorScheme;
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11pub(super) type Properties = HashMap<String, ResolvedDeclaration>;
12
13struct Declaration {
14    name: String,
15    value: CssValue,
16    important: bool,
17}
18
19/// Origin of a declaration in the CSS cascade.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub enum StyleOrigin {
22    /// Browser-provided default styling.
23    UserAgent,
24    /// Styling supplied by the loaded document.
25    Author,
26}
27
28/// A single CSS declaration after selector resolution and value processing.
29///
30/// `ResolvedDeclaration` represents one property-value pair that has been
31/// fully associated with a selector and enriched with all information
32/// required for CSS cascade resolution.
33///
34/// This structure is produced after:
35/// - Parsing selectors
36/// - Resolving `var()` using custom properties
37/// - Computing selector specificity
38///
39/// During the cascade phase, multiple `ResolvedDeclaration`s with the same
40/// property name may compete. The winner is determined by comparing:
41///
42/// 1. `important` declarations
43/// 2. cascade `origin`
44/// 3. `specificity` (higher specificity wins)
45/// 4. `order` (later declarations win)
46#[derive(Debug, Clone)]
47pub struct ResolvedDeclaration {
48    pub selector: Arc<ComplexSelector>,
49    pub name: String,
50    pub value: CssValue,
51    pub specificity: (u32, u32, u32),
52    pub order: usize,
53    pub important: bool,
54    pub origin: StyleOrigin,
55    /// Nested `@media`/`@supports` conditions which must all match before this
56    /// declaration applies. Shared across declarations of the same rule block.
57    pub media_queries: Arc<Vec<AtQuery>>,
58}
59
60pub type ResolvedStyles = Vec<ResolvedDeclaration>;
61
62/// Indexed collection of resolved CSS declarations partitioned by selector subject.
63///
64/// Instead of matching every declaration against every DOM element
65/// `O(N_DOM * M_CSS)`, declarations are grouped by *selector* and
66/// indexed by their subject selector (rightmost part):
67/// - ID selector (`#id`)
68/// - Class selectors (`.class`)
69/// - Tag name selector (`div`, `p`, etc.)
70/// - Attribute selectors (`[hidden]`)
71/// - Universal / pseudo-class-only selectors
72///
73/// Declarations produced by one rule block share an identical selector, so
74/// grouped matching evaluates each selector at most once per element instead of
75/// once per declaration.
76#[derive(Debug, Clone, Default)]
77pub struct RuleSet {
78    declarations: Vec<ResolvedDeclaration>,
79    /// Declarations sharing a structurally identical selector, so a selector is
80    /// matched once per element for the whole group.
81    groups: Vec<SelectorGroup>,
82    id_rules: HashMap<String, Vec<usize>>,
83    class_rules: HashMap<String, Vec<usize>>,
84    tag_rules: HashMap<String, Vec<usize>>,
85    attribute_rules: HashMap<String, Vec<usize>>,
86    universal_rules: Vec<usize>,
87}
88
89/// Declarations whose selectors are structurally identical. Matching
90/// `selector` once is sufficient to cascade every declaration in `decls`;
91/// `decls` holds indices into [`RuleSet::declarations`].
92#[derive(Debug, Clone)]
93pub struct SelectorGroup {
94    pub selector: Arc<ComplexSelector>,
95    pub decls: Vec<usize>,
96}
97
98impl RuleSet {
99    /// Builds a `RuleSet` holding a copy of a list of `ResolvedDeclaration`s, pre-filtering by `MediaEnvironment`.
100    pub fn from_declarations(
101        declarations: &[ResolvedDeclaration],
102        media_env: &MediaEnvironment,
103    ) -> Self {
104        // First pass: media-filter and group declarations by selector. All
105        // declarations of one rule block share an identical selector, so each
106        // unique selector is matched once per element for all its declarations.
107        let mut groups = Vec::<SelectorGroup>::new();
108        let mut group_by_selector: HashMap<Arc<ComplexSelector>, usize> = HashMap::new();
109        for (idx, decl) in declarations.iter().enumerate() {
110            // Media query evaluation done ONCE per declaration during RuleSet construction!
111            if !decl.matches_media(media_env) {
112                continue;
113            }
114            match group_by_selector.get(&decl.selector) {
115                Some(&group_idx) => groups[group_idx].decls.push(idx),
116                None => {
117                    let group_idx = groups.len();
118                    groups.push(SelectorGroup {
119                        selector: Arc::clone(&decl.selector),
120                        decls: vec![idx],
121                    });
122                    group_by_selector.insert(Arc::clone(&decl.selector), group_idx);
123                }
124            }
125        }
126
127        let mut id_rules: HashMap<String, Vec<usize>> = HashMap::new();
128        let mut class_rules: HashMap<String, Vec<usize>> = HashMap::new();
129        let mut tag_rules: HashMap<String, Vec<usize>> = HashMap::new();
130        let mut attribute_rules: HashMap<String, Vec<usize>> = HashMap::new();
131        let mut universal_rules = Vec::new();
132
133        for (group_idx, group) in groups.iter().enumerate() {
134            // ComplexSelector parts are stored right-to-left: parts[0] is the subject selector.
135            if let Some(subject_part) = group.selector.parts.first() {
136                let sel = &subject_part.selector;
137                if let Some(id) = &sel.id {
138                    id_rules.entry(id.clone()).or_default().push(group_idx);
139                } else if let Some(first_class) = sel.classes.first() {
140                    // Index by the first class to prevent duplicate indexing
141                    class_rules
142                        .entry(first_class.clone())
143                        .or_default()
144                        .push(group_idx);
145                } else if let Some(tag) = &sel.tag {
146                    tag_rules.entry(tag.clone()).or_default().push(group_idx);
147                } else if let Some(first_attr) = sel.attributes.first() {
148                    attribute_rules
149                        .entry(first_attr.name.clone())
150                        .or_default()
151                        .push(group_idx);
152                } else {
153                    universal_rules.push(group_idx);
154                }
155            } else {
156                universal_rules.push(group_idx);
157            }
158        }
159
160        Self {
161            declarations: declarations.to_vec(),
162            groups,
163            id_rules,
164            class_rules,
165            tag_rules,
166            attribute_rules,
167            universal_rules,
168        }
169    }
170
171    /// Returns an iterator over selector candidates that might match the given
172    /// element, each unique selector at most once per element.
173    pub fn query_candidates(
174        &self,
175        element: &crate::engine::css::matcher::ElementInfo,
176    ) -> impl Iterator<Item = &SelectorGroup> {
177        let universal = self.universal_rules.iter().map(|&g| &self.groups[g]);
178
179        let id = element
180            .id
181            .as_ref()
182            .and_then(|id_str| self.id_rules.get(id_str))
183            .into_iter()
184            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
185
186        let tag = self
187            .tag_rules
188            .get(&element.tag_name)
189            .into_iter()
190            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
191
192        let classes = element
193            .classes
194            .iter()
195            .filter_map(|class_str| self.class_rules.get(class_str))
196            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
197
198        let attributes = element
199            .attributes
200            .iter()
201            .filter_map(|(name, _)| self.attribute_rules.get(name))
202            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
203
204        universal
205            .chain(id)
206            .chain(tag)
207            .chain(classes)
208            .chain(attributes)
209    }
210
211    pub fn declarations(&self) -> &[ResolvedDeclaration] {
212        &self.declarations
213    }
214}
215
216impl ResolvedDeclaration {
217    /// Returns whether this declaration wins over another matching declaration.
218    pub fn outranks(&self, other: &Self) -> bool {
219        (self.important, self.origin, self.specificity, self.order)
220            > (
221                other.important,
222                other.origin,
223                other.specificity,
224                other.order,
225            )
226    }
227
228    /// Returns whether every enclosing `@media` rule matches `environment`.
229    pub fn matches_media(&self, environment: &MediaEnvironment) -> bool {
230        self.media_queries
231            .iter()
232            .all(|query| MediaEvaluator::evaluate(query, environment))
233    }
234}
235
236/// Values used to evaluate media queries for the current page.
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct MediaEnvironment {
239    /// Width of the page viewport in CSS pixels.
240    pub viewport_width: f32,
241    /// Height of the page viewport in CSS pixels.
242    pub viewport_height: f32,
243    /// Operating-system color preference used by `prefers-color-scheme`.
244    pub color_scheme: ColorScheme,
245}
246
247impl MediaEnvironment {
248    pub fn new(viewport: (f32, f32), color_scheme: ColorScheme) -> Self {
249        Self {
250            viewport_width: viewport.0,
251            viewport_height: viewport.1,
252            color_scheme,
253        }
254    }
255}
256
257/// Keeps only declarations whose enclosing media queries currently match.
258pub fn filter_media<'a>(
259    styles: &'a ResolvedStyles,
260    environment: &'a MediaEnvironment,
261) -> impl Iterator<Item = &'a ResolvedDeclaration> {
262    styles
263        .iter()
264        .filter(move |declaration| declaration.matches_media(environment))
265}
266
267/// Appends resolved declarations while preserving source order across stylesheets.
268pub fn append_resolved_styles(target: &mut ResolvedStyles, mut incoming: ResolvedStyles) {
269    let next_order = target
270        .iter()
271        .map(|declaration| declaration.order)
272        .max()
273        .map_or(0, |order| order + 1);
274    for declaration in &mut incoming {
275        declaration.order += next_order;
276    }
277    target.extend(incoming);
278}
279
280/// Resolves a `style` attribute's declarations into (name, value, important)
281/// triples, applying `var()` and `!important` handling like a rule block.
282///
283/// Inline styles participate in the cascade as author-origin declarations with
284/// the highest specificity, so callers should apply them after stylesheet
285/// declarations for the same element.
286pub fn resolve_inline_style(style_attr: &str) -> Vec<(String, CssValue, bool)> {
287    let mut parser = crate::engine::css::parser::Parser::new(style_attr);
288    let Ok(nodes) = parser.parse_declarations() else {
289        return Vec::new();
290    };
291
292    let declarations = DeclarationResolver::collect(&nodes);
293    let mut custom_properties = Properties::new();
294    for declaration in &declarations {
295        if declaration.name.starts_with("--") {
296            set_inline_custom_property(
297                &mut custom_properties,
298                declaration.name.clone(),
299                declaration.value.clone(),
300                declaration.important,
301            );
302        }
303    }
304
305    declarations
306        .into_iter()
307        .map(|declaration| {
308            let value = if declaration.name.starts_with("--") {
309                declaration.value
310            } else {
311                DeclarationResolver::resolve_var(
312                    &declaration.value,
313                    &custom_properties,
314                    &mut HashSet::new(),
315                )
316                .unwrap_or(declaration.value)
317            };
318            (declaration.name, value, declaration.important)
319        })
320        .collect()
321}
322
323pub fn resolve_inline_value(value: &str) -> Option<CssValue> {
324    let mut tokenizer = crate::engine::css::tokenizer::Tokenizer::new(value);
325    let mut tokens = Vec::new();
326
327    loop {
328        let token = tokenizer.next_token();
329        if token == crate::engine::css::tokenizer::Token::EOF {
330            break;
331        }
332
333        tokens.push(token);
334    }
335
336    let Ok(value) = crate::engine::css::parser::Parser::parse_tokens_to_css_value(tokens) else {
337        return None;
338    };
339
340    Some(value)
341}
342
343/// Adds an inline custom property to an element's inherited property map.
344/// Inline author declarations outrank stylesheet declarations unless the
345/// stylesheet winner is `!important` and the inline declaration is not.
346pub(super) fn set_inline_custom_property(
347    properties: &mut Properties,
348    name: String,
349    value: CssValue,
350    important: bool,
351) {
352    if properties
353        .get(&name)
354        .is_some_and(|current| current.important && !important)
355    {
356        return;
357    }
358
359    properties.insert(
360        name.clone(),
361        ResolvedDeclaration {
362            selector: Arc::new(ComplexSelector { parts: Vec::new() }),
363            name,
364            value,
365            specificity: (u32::MAX, u32::MAX, u32::MAX),
366            order: usize::MAX,
367            important,
368            origin: StyleOrigin::Author,
369            media_queries: Arc::new(Vec::new()),
370        },
371    );
372}
373
374// ============================================================
375//  CssResolver — tree walk + rule resolution
376// ============================================================
377
378pub struct CssResolver;
379
380impl CssResolver {
381    /// Resolves an author stylesheet into declarations used by layout.
382    pub fn resolve(stylesheet: &CssNode) -> ResolvedStyles {
383        Self::resolve_with_origin(stylesheet, StyleOrigin::Author)
384    }
385
386    /// Resolves a stylesheet using the supplied cascade origin.
387    pub fn resolve_with_origin(stylesheet: &CssNode, origin: StyleOrigin) -> ResolvedStyles {
388        let mut styles = Vec::new();
389        let mut order = 0;
390        Self::walk(
391            stylesheet,
392            &mut styles,
393            &mut order,
394            origin,
395            &mut Vec::new(),
396            &[],
397        );
398        styles
399    }
400
401    /// Parses declarations from an HTML `style` attribute.
402    ///
403    /// Inline declarations use author origin but outrank selector-based author
404    /// rules of the same importance. Author `!important` declarations still
405    /// outrank normal inline declarations, as required by the cascade.
406    pub fn resolve_inline_style(style: &str) -> ResolvedStyles {
407        let source = format!("* {{ {style} }}");
408        let Ok(stylesheet) = crate::engine::css::parser::Parser::new(&source).parse() else {
409            return Vec::new();
410        };
411        let mut declarations = Self::resolve_with_origin(&stylesheet, StyleOrigin::Author);
412        for declaration in &mut declarations {
413            declaration.specificity = (u32::MAX, u32::MAX, u32::MAX);
414            declaration.order = usize::MAX;
415        }
416        declarations
417    }
418
419    fn walk(
420        node: &CssNode,
421        styles: &mut ResolvedStyles,
422        order: &mut usize,
423        origin: StyleOrigin,
424        media_queries: &mut Vec<AtQuery>,
425        parent_selectors: &[ComplexSelector],
426    ) {
427        if let CssNodeType::AtRule { name, params } = node.node() {
428            if name.eq_ignore_ascii_case("supports") && !SupportsEvaluator::evaluate(params) {
429                return;
430            }
431
432            let is_media = name.eq_ignore_ascii_case("media");
433            if is_media {
434                media_queries.push(params.clone());
435            }
436            for child in node.children() {
437                Self::walk(
438                    child,
439                    styles,
440                    order,
441                    origin,
442                    media_queries,
443                    parent_selectors,
444                );
445            }
446            if is_media {
447                media_queries.pop();
448            }
449            return;
450        }
451
452        let resolved_selectors =
453            Self::resolve_rule(node, styles, order, origin, media_queries, parent_selectors);
454
455        for child in node.children() {
456            Self::walk(
457                child,
458                styles,
459                order,
460                origin,
461                media_queries,
462                &resolved_selectors,
463            );
464        }
465    }
466
467    fn resolve_rule(
468        node: &CssNode,
469        styles: &mut ResolvedStyles,
470        order: &mut usize,
471        origin: StyleOrigin,
472        media_queries: &[AtQuery],
473        parent_selectors: &[ComplexSelector],
474    ) -> Vec<ComplexSelector> {
475        let CssNodeType::Rule { selectors } = node.node() else {
476            return vec![];
477        };
478
479        let resolved_selectors: Vec<ComplexSelector> = selectors
480            .iter()
481            .flat_map(|child| {
482                if parent_selectors.is_empty() {
483                    vec![child.clone()]
484                } else {
485                    parent_selectors
486                        .iter()
487                        .map(|parent| parent.nest(child))
488                        .collect::<Vec<_>>()
489                }
490            })
491            .collect();
492
493        let declarations = DeclarationResolver::collect(node.children());
494
495        for selector in resolved_selectors.iter() {
496            Self::push_resolved(
497                selector,
498                &declarations,
499                styles,
500                order,
501                origin,
502                media_queries,
503            );
504        }
505
506        resolved_selectors
507    }
508
509    fn push_resolved(
510        selector: &ComplexSelector,
511        declarations: &[Declaration],
512        styles: &mut ResolvedStyles,
513        order: &mut usize,
514        origin: StyleOrigin,
515        media_queries: &[AtQuery],
516    ) {
517        let specificity = selector.specificity();
518        let selector = Arc::new(selector.clone());
519        let media_queries = Arc::new(media_queries.to_vec());
520
521        for decl in declarations {
522            styles.push(ResolvedDeclaration {
523                selector: Arc::clone(&selector),
524                name: decl.name.clone(),
525                value: decl.value.clone(),
526                specificity,
527                order: *order,
528                important: decl.important,
529                origin,
530                media_queries: Arc::clone(&media_queries),
531            });
532            *order += 1;
533        }
534    }
535}
536
537struct MediaEvaluator;
538
539impl MediaEvaluator {
540    fn evaluate(query: &AtQuery, environment: &MediaEnvironment) -> bool {
541        match query {
542            AtQuery::Group(items) => Self::evaluate_group(items, environment),
543            item => Self::evaluate_clause(std::slice::from_ref(item), environment),
544        }
545    }
546
547    fn evaluate_clause(items: &[AtQuery], environment: &MediaEnvironment) -> bool {
548        if items.is_empty() {
549            return false;
550        }
551        let negate = matches!(items.first(), Some(AtQuery::Keyword(keyword)) if keyword.eq_ignore_ascii_case("not"));
552        let mut saw_operand = false;
553        let matches = items
554            .iter()
555            .skip(usize::from(negate))
556            .all(|item| match item {
557                AtQuery::Keyword(keyword)
558                    if keyword.eq_ignore_ascii_case("and")
559                        || keyword.eq_ignore_ascii_case("only") =>
560                {
561                    true
562                }
563                AtQuery::Keyword(keyword)
564                    if keyword.eq_ignore_ascii_case("all")
565                        || keyword.eq_ignore_ascii_case("screen") =>
566                {
567                    saw_operand = true;
568                    true
569                }
570                AtQuery::Keyword(keyword) if keyword.eq_ignore_ascii_case("print") => {
571                    saw_operand = true;
572                    false
573                }
574                AtQuery::Keyword(_) => {
575                    saw_operand = true;
576                    false
577                }
578                AtQuery::Condition { name, value } => {
579                    saw_operand = true;
580                    Self::evaluate_condition(name, value, environment)
581                }
582                AtQuery::Range { left, name, right } => {
583                    saw_operand = true;
584                    Self::evaluate_range(left.as_ref(), name, right.as_ref(), environment)
585                }
586                AtQuery::Group(group) => {
587                    saw_operand = true;
588                    Self::evaluate_group(group, environment)
589                }
590            });
591        if !saw_operand {
592            return false;
593        }
594        if negate { !matches } else { matches }
595    }
596
597    fn evaluate_group(items: &[AtQuery], environment: &MediaEnvironment) -> bool {
598        items
599            .split(|item| matches!(item, AtQuery::Keyword(keyword) if keyword == ","))
600            .any(|clause| Self::evaluate_clause(clause, environment))
601    }
602
603    fn evaluate_range(
604        left: Option<&(CssValue, RangeOperator)>,
605        name: &str,
606        right: Option<&(RangeOperator, CssValue)>,
607        environment: &MediaEnvironment,
608    ) -> bool {
609        let name = name.to_ascii_lowercase();
610
611        let actual = match name.as_str() {
612            "width" => environment.viewport_width,
613            "height" => environment.viewport_height,
614            _ => return false,
615        };
616
617        if let Some((value, operator)) = left {
618            let Some(expected) = Self::length_px(value, environment) else {
619                return false;
620            };
621
622            if !Self::compare_range(expected, *operator, actual) {
623                return false;
624            }
625        }
626
627        if let Some((operator, value)) = right {
628            let Some(expected) = Self::length_px(value, environment) else {
629                return false;
630            };
631
632            if !Self::compare_range(actual, *operator, expected) {
633                return false;
634            }
635        }
636
637        true
638    }
639
640    fn compare_range(actual: f32, operator: RangeOperator, expected: f32) -> bool {
641        match operator {
642            RangeOperator::Less => actual < expected,
643            RangeOperator::LessEqual => actual <= expected,
644            RangeOperator::Equal => (actual - expected).abs() <= f32::EPSILON,
645            RangeOperator::GreaterEqual => actual >= expected,
646            RangeOperator::Greater => actual > expected,
647        }
648    }
649
650    fn evaluate_condition(name: &str, value: &CssValue, environment: &MediaEnvironment) -> bool {
651        let name = name.to_ascii_lowercase();
652        match name.as_str() {
653            "width" | "min-width" | "max-width" => {
654                Self::compare_length(&name, value, environment.viewport_width, environment)
655            }
656            "height" | "min-height" | "max-height" => {
657                Self::compare_length(&name, value, environment.viewport_height, environment)
658            }
659            "orientation" => match value {
660                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("portrait") => {
661                    environment.viewport_height >= environment.viewport_width
662                }
663                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("landscape") => {
664                    environment.viewport_width > environment.viewport_height
665                }
666                _ => false,
667            },
668            "prefers-color-scheme" => match value {
669                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("dark") => {
670                    environment.color_scheme == ColorScheme::Dark
671                }
672                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("light") => {
673                    environment.color_scheme == ColorScheme::Light
674                }
675                _ => false,
676            },
677            _ => false,
678        }
679    }
680
681    fn compare_length(
682        name: &str,
683        value: &CssValue,
684        actual: f32,
685        environment: &MediaEnvironment,
686    ) -> bool {
687        let Some(expected) = Self::length_px(value, environment) else {
688            return false;
689        };
690        if name.starts_with("min-") {
691            actual >= expected
692        } else if name.starts_with("max-") {
693            actual <= expected
694        } else {
695            (actual - expected).abs() <= f32::EPSILON
696        }
697    }
698
699    fn length_px(value: &CssValue, environment: &MediaEnvironment) -> Option<f32> {
700        let text_flow = crate::engine::layouter::types::TextFlowStyle::default();
701        let len = resolve_css_len("media", std::slice::from_ref(value), &text_flow)?;
702        len.resolve_with(
703            None,
704            environment.viewport_width,
705            environment.viewport_height,
706        )
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::engine::css::parser::Parser;
714    use crate::engine::css::values::Unit;
715
716    fn resolve(css: &str, origin: StyleOrigin) -> ResolvedStyles {
717        let stylesheet = Parser::new(css).parse().unwrap();
718        CssResolver::resolve_with_origin(&stylesheet, origin)
719    }
720
721    #[test]
722    fn author_declaration_outranks_more_specific_user_agent_declaration() {
723        let user_agent = resolve(
724            r#"input[type="text"] { display: inline-block; }"#,
725            StyleOrigin::UserAgent,
726        );
727        let author = resolve("input { display: block; }", StyleOrigin::Author);
728
729        assert!(author[0].outranks(&user_agent[0]));
730    }
731
732    #[test]
733    fn append_rebases_order_across_stylesheets() {
734        let mut styles = resolve("input { display: inline; }", StyleOrigin::Author);
735        let later = resolve("input { display: block; }", StyleOrigin::Author);
736        append_resolved_styles(&mut styles, later);
737
738        assert!(styles[1].order > styles[0].order);
739        assert!(styles[1].outranks(&styles[0]));
740    }
741
742    #[test]
743    fn resolve_inline_style_extracts_declarations_and_important() {
744        let decls = resolve_inline_style("color: red; margin: 4px 8px !important; width: 10px");
745        assert_eq!(decls.len(), 3);
746
747        assert_eq!(decls[0].0, "color");
748        assert!(!decls[0].2);
749
750        assert_eq!(decls[1].0, "margin");
751        assert!(decls[1].2);
752
753        assert_eq!(decls[2].0, "width");
754    }
755
756    #[test]
757    fn resolve_inline_style_tolerates_empty_and_malformed_input() {
758        assert!(resolve_inline_style("").is_empty());
759        assert!(resolve_inline_style(";;;").is_empty());
760    }
761
762    #[test]
763    fn resolve_inline_style_resolves_var() {
764        let decls = resolve_inline_style("--accent: blue; color: var(--accent)");
765        assert_eq!(decls.len(), 2);
766
767        let (_, color_value, _) = decls.iter().find(|(n, _, _)| n == "color").unwrap();
768        assert_eq!(color_value, &CssValue::Keyword("blue".into()));
769    }
770
771    #[test]
772    fn media_width_conditions_follow_viewport() {
773        let styles = resolve(
774            "@media screen and (max-width: 600px) { div { color: red; } }",
775            StyleOrigin::Author,
776        );
777        let narrow = MediaEnvironment::new((600.0, 800.0), ColorScheme::Light);
778        let wide = MediaEnvironment::new((601.0, 800.0), ColorScheme::Light);
779
780        assert_eq!(filter_media(&styles, &narrow).count(), 1);
781        assert_eq!(filter_media(&styles, &wide).count(), 0);
782    }
783
784    #[test]
785    fn scratch_style_adjacent_media_conditions_follow_desktop_viewport() {
786        let stylesheet = Parser::new(
787            "@media only screen and (max-width : 479px){#view{text-align:center}.inner{margin:0 auto;width:100%}}@media only screen and (min-width : 480px)and (max-width : 767px){#view{text-align:center}.inner{margin:0 auto;width:480px}}@media only screen and (min-width : 768px)and (max-width : 941px){#view{text-align:center}.inner{margin:0 auto;width:768px}}@media only screen and (min-width : 942px){.inner{margin:0 auto;width:942px}}html,body{display:block}",
788        )
789        .parse_lossy();
790        let styles = CssResolver::resolve_with_origin(&stylesheet, StyleOrigin::Author);
791        let desktop = MediaEnvironment::new((1280.0, 800.0), ColorScheme::Light);
792        let declarations = filter_media(&styles, &desktop)
793            .filter(|declaration| declaration.name == "width")
794            .collect::<Vec<_>>();
795
796        assert_eq!(declarations.len(), 1);
797        assert_eq!(declarations[0].name, "width");
798        assert_eq!(declarations[0].value, CssValue::Length(942.0, Unit::Px));
799    }
800
801    #[test]
802    fn media_query_lists_use_or_semantics() {
803        let styles = resolve(
804            "@media print, (orientation: landscape) { div { color: red; } }",
805            StyleOrigin::Author,
806        );
807        let landscape = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
808        let portrait = MediaEnvironment::new((600.0, 800.0), ColorScheme::Light);
809
810        assert_eq!(filter_media(&styles, &landscape).count(), 1);
811        assert_eq!(filter_media(&styles, &portrait).count(), 0);
812    }
813
814    #[test]
815    fn media_color_scheme_matches_system_preference() {
816        let styles = resolve(
817            "@media (prefers-color-scheme: dark) { div { color: white; } }",
818            StyleOrigin::Author,
819        );
820        let light = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
821        let dark = MediaEnvironment::new((800.0, 600.0), ColorScheme::Dark);
822
823        assert_eq!(filter_media(&styles, &light).count(), 0);
824        assert_eq!(filter_media(&styles, &dark).count(), 1);
825    }
826
827    #[test]
828    fn empty_media_query_does_not_match() {
829        let styles = resolve("@media { div { color: red; } }", StyleOrigin::Author);
830        let environment = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
831
832        assert_eq!(filter_media(&styles, &environment).count(), 0);
833    }
834
835    #[test]
836    fn test_rule_set_partitioning_and_querying() {
837        let styles = resolve(
838            r#"
839            * { margin: 0; }
840            #header { color: red; }
841            .btn { display: inline-block; }
842            span { font-size: 12px; }
843            div.container { padding: 10px; }
844            "#,
845            StyleOrigin::Author,
846        );
847        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
848        let rule_set = RuleSet::from_declarations(&styles, &env);
849        let decls_of = |group: &SelectorGroup| -> Vec<&ResolvedDeclaration> {
850            group
851                .decls
852                .iter()
853                .map(|&idx| &rule_set.declarations()[idx])
854                .collect()
855        };
856
857        // Test element 1: <div id="header" class="btn">
858        let el1 = crate::engine::css::matcher::ElementInfo {
859            tag_name: "div".to_string(),
860            id: Some("header".to_string()),
861            classes: vec!["btn".to_string()],
862            ..Default::default()
863        };
864        let candidates1: Vec<_> = rule_set.query_candidates(&el1).flat_map(decls_of).collect();
865        // Should match universal (*), id (#header), and class (.btn)
866        assert!(candidates1.iter().any(|d| d.name == "margin"));
867        assert!(candidates1.iter().any(|d| d.name == "color"));
868        assert!(candidates1.iter().any(|d| d.name == "display"));
869        // Should NOT include span rule
870        assert!(!candidates1.iter().any(|d| d.name == "font-size"));
871
872        // Test element 2: <span class="other">
873        let el2 = crate::engine::css::matcher::ElementInfo {
874            tag_name: "span".to_string(),
875            id: None,
876            classes: vec!["other".to_string()],
877            ..Default::default()
878        };
879        let candidates2: Vec<_> = rule_set.query_candidates(&el2).flat_map(decls_of).collect();
880        // Should match universal (*) and tag (span)
881        assert!(candidates2.iter().any(|d| d.name == "margin"));
882        assert!(candidates2.iter().any(|d| d.name == "font-size"));
883        // Should NOT include id (#header) or class (.btn)
884        assert!(!candidates2.iter().any(|d| d.name == "color"));
885        assert!(!candidates2.iter().any(|d| d.name == "display"));
886    }
887
888    #[test]
889    fn rule_set_groups_shared_selectors() {
890        let styles = resolve(
891            r#"
892            div { color: red; font-size: 12px; }
893            div { margin: 0; }
894            p { padding: 1px; }
895            p.padded { padding: 2px; }
896            "#,
897            StyleOrigin::Author,
898        );
899        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
900        let rule_set = RuleSet::from_declarations(&styles, &env);
901
902        // The two `div` blocks share an identical selector, so all three
903        // declarations merge into a single group.
904        let div = crate::engine::css::matcher::ElementInfo {
905            tag_name: "div".to_string(),
906            ..Default::default()
907        };
908        let groups: Vec<_> = rule_set.query_candidates(&div).collect();
909        assert_eq!(groups.len(), 1);
910        assert_eq!(groups[0].decls.len(), 3);
911
912        // Plain `p` only matches the `p` group.
913        let p = crate::engine::css::matcher::ElementInfo {
914            tag_name: "p".to_string(),
915            ..Default::default()
916        };
917        let groups: Vec<_> = rule_set.query_candidates(&p).collect();
918        assert_eq!(groups.len(), 1);
919
920        // Distinct selectors stay in distinct groups.
921        let padded = crate::engine::css::matcher::ElementInfo {
922            tag_name: "p".to_string(),
923            classes: vec!["padded".to_string()],
924            ..Default::default()
925        };
926        let groups: Vec<_> = rule_set.query_candidates(&padded).collect();
927        assert_eq!(groups.len(), 2);
928    }
929
930    #[test]
931    fn rule_set_indexes_attribute_subjects() {
932        let styles = resolve(
933            r#"
934            [hidden] { display: none; }
935            [data-tip] { position: relative; }
936            "#,
937            StyleOrigin::Author,
938        );
939        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
940        let rule_set = RuleSet::from_declarations(&styles, &env);
941
942        let hidden = crate::engine::css::matcher::ElementInfo {
943            tag_name: "div".to_string(),
944            attributes: vec![("hidden".to_string(), String::new())],
945            ..Default::default()
946        };
947        let candidates: Vec<_> = rule_set
948            .query_candidates(&hidden)
949            .flat_map(|group| group.decls.iter())
950            .collect();
951        assert_eq!(candidates.len(), 1, "only the [hidden] group is queried");
952
953        let plain = crate::engine::css::matcher::ElementInfo {
954            tag_name: "div".to_string(),
955            ..Default::default()
956        };
957        let candidates: Vec<_> = rule_set
958            .query_candidates(&plain)
959            .flat_map(|group| group.decls.iter())
960            .collect();
961        assert_eq!(
962            candidates.len(),
963            0,
964            "no candidates for attribute-less element"
965        );
966    }
967
968    #[test]
969    fn media_width_calc_rem_is_resolved() {
970        let styles = resolve(
971            "@media (width > calc(19rem)) { div { color: red; } }",
972            StyleOrigin::Author,
973        );
974        // 19rem = 304px
975        let wide = MediaEnvironment::new((400.0, 800.0), ColorScheme::Light);
976        let narrow = MediaEnvironment::new((200.0, 800.0), ColorScheme::Light);
977        assert_eq!(filter_media(&styles, &wide).count(), 1);
978        assert_eq!(filter_media(&styles, &narrow).count(), 0);
979    }
980
981    #[test]
982    fn media_range_calc_vw() {
983        let styles = resolve(
984            "@media (width > calc(100vw - 40px)) { div { color: red; } }",
985            StyleOrigin::Author,
986        );
987        // 100vw = viewport_width, so condition is width > (viewport_width - 40)
988        let env = MediaEnvironment::new((100.0, 800.0), ColorScheme::Light);
989        // 100 > (100 - 40) = 60 => true
990        assert_eq!(filter_media(&styles, &env).count(), 1);
991        let env2 = MediaEnvironment::new((30.0, 800.0), ColorScheme::Light);
992        // 30 > (30 - 40) = -10 => true
993        assert_eq!(filter_media(&styles, &env2).count(), 1);
994    }
995
996    #[test]
997    fn media_min_width_rem() {
998        let styles = resolve(
999            "@media (min-width: 2rem) { div { color: red; } }",
1000            StyleOrigin::Author,
1001        );
1002        // 2rem = 32px
1003        let env = MediaEnvironment::new((40.0, 800.0), ColorScheme::Light);
1004        assert_eq!(filter_media(&styles, &env).count(), 1);
1005        let env2 = MediaEnvironment::new((20.0, 800.0), ColorScheme::Light);
1006        assert_eq!(filter_media(&styles, &env2).count(), 0);
1007    }
1008
1009    #[test]
1010    fn media_width_calc_min_function() {
1011        let styles = resolve(
1012            "@media (width > min(800px, 90vw)) { div { color: red; } }",
1013            StyleOrigin::Author,
1014        );
1015        // viewport 1000px: min(800, 900) = 800, 1000 > 800 => true
1016        let env = MediaEnvironment::new((1000.0, 800.0), ColorScheme::Light);
1017        assert_eq!(filter_media(&styles, &env).count(), 1);
1018        // viewport 500px: min(800, 450) = 450, 500 > 450 => true
1019        let env2 = MediaEnvironment::new((500.0, 800.0), ColorScheme::Light);
1020        assert_eq!(filter_media(&styles, &env2).count(), 1);
1021        // viewport 400px: min(800, 360) = 360, 400 > 360 => true
1022        let env3 = MediaEnvironment::new((400.0, 800.0), ColorScheme::Light);
1023        assert_eq!(filter_media(&styles, &env3).count(), 1);
1024        // viewport 300px: min(800, 270) = 270, 300 > 270 => true
1025        let env4 = MediaEnvironment::new((300.0, 800.0), ColorScheme::Light);
1026        assert_eq!(filter_media(&styles, &env4).count(), 1);
1027    }
1028}
1029
1030// ============================================================
1031//  SupportsEvaluator — `@supports` condition evaluation
1032// ============================================================
1033
1034struct SupportsEvaluator;
1035
1036impl SupportsEvaluator {
1037    /// Dispatch on the `AtQuery` AST variant.
1038    fn evaluate(query: &AtQuery) -> bool {
1039        match query {
1040            // `@supports (display: grid)` — a parenthesised group
1041            AtQuery::Group(items) => Self::evaluate_group(items),
1042            // `@supports (display: grid)` — the inner condition after unwrapping
1043            AtQuery::Condition { name, value } => Self::is_supported(name, value),
1044            // Media query range syntax is not a @supports condition.
1045            AtQuery::Range { .. } => false,
1046            // Stray keyword outside a group (malformed input)
1047            AtQuery::Keyword(_) => false,
1048        }
1049    }
1050
1051    /// Evaluate a group of `@supports` items.
1052    ///
1053    /// The parser produces flat groups like:
1054    /// - `(display: grid)` → `[Group([Condition])]`
1055    /// - `(A) and (B)` → `[Group, Keyword("and"), Group]`
1056    /// - `not (A)` → `[Keyword("not"), Group]`
1057    /// - `(A) or (B)` → `[Group, Keyword("or"), Group]`
1058    fn evaluate_group(items: &[AtQuery]) -> bool {
1059        if items.is_empty() {
1060            return false;
1061        }
1062
1063        // `not (display: grid)` — negate
1064        if matches!(items.first(), Some(AtQuery::Keyword(k)) if k.eq_ignore_ascii_case("not")) {
1065            return items.len() > 1 && !Self::evaluate(&AtQuery::Group(items[1..].to_vec()));
1066        }
1067
1068        // `(display: flex) and (gap: 10px)` — all operands must be supported
1069        if let Some(operands) = Self::split_by_keyword(items, "and") {
1070            operands.iter().all(|g| Self::evaluate(g))
1071        // `(display: flex) or (display: grid)` — at least one must be supported
1072        } else if let Some(operands) = Self::split_by_keyword(items, "or") {
1073            operands.iter().any(|g| Self::evaluate(g))
1074        // Single group — unwrap one level
1075        } else if items.len() == 1 {
1076            Self::evaluate(&items[0])
1077        } else {
1078            items.iter().all(Self::evaluate)
1079        }
1080    }
1081
1082    fn is_supported(name: &str, value: &CssValue) -> bool {
1083        super::builder::apply_declaration(
1084            name,
1085            value,
1086            &mut ui_layout::Style::default(),
1087            &mut super::types::ContainerStyle::default(),
1088            &mut super::types::TextStyle::default(),
1089            &mut super::types::TextFlowStyle::default(),
1090            &ui_layout::Style::default(),
1091            &super::types::ContainerStyle::default(),
1092            &super::types::TextStyle::default(),
1093            &super::types::TextFlowStyle::default(),
1094            &mut super::types::Overflow::default(),
1095            super::types::ColorScheme::Light,
1096        )
1097        .is_some()
1098    }
1099
1100    fn split_by_keyword<'a>(items: &'a [AtQuery], keyword: &str) -> Option<Vec<&'a AtQuery>> {
1101        let has = items
1102            .iter()
1103            .any(|item| matches!(item, AtQuery::Keyword(k) if k.eq_ignore_ascii_case(keyword)));
1104        if !has {
1105            return None;
1106        }
1107        Some(
1108            items
1109                .iter()
1110                .filter(
1111                    |item| !matches!(item, AtQuery::Keyword(k) if k.eq_ignore_ascii_case(keyword)),
1112                )
1113                .collect(),
1114        )
1115    }
1116}
1117
1118// ============================================================
1119//  DeclarationResolver — `!important` extraction, `var()` resolution
1120// ============================================================
1121
1122pub(super) struct DeclarationResolver;
1123
1124impl DeclarationResolver {
1125    fn collect(children: &[CssNode]) -> Vec<Declaration> {
1126        let mut result = Vec::new();
1127
1128        for child in children {
1129            let CssNodeType::Declaration { name, value } = &child.node() else {
1130                continue;
1131            };
1132
1133            let (value, important) = Self::extract_important(value);
1134
1135            result.push(Declaration {
1136                name: name.clone(),
1137                value,
1138                important,
1139            });
1140        }
1141
1142        result
1143    }
1144
1145    /// Extract `!important` from a CSS value.
1146    ///
1147    /// `border: 1px solid black !important` is parsed as a `List` where the
1148    /// last two items are `Keyword("!")` and `Keyword("important")`.
1149    fn extract_important(value: &CssValue) -> (CssValue, bool) {
1150        match value {
1151            CssValue::List(list) if list.len() >= 2 => {
1152                let len = list.len();
1153                let is_important = matches!(
1154                    (&list[len - 2], &list[len - 1]),
1155                    (
1156                        CssValue::Keyword(bang),
1157                        CssValue::Keyword(ident)
1158                    )
1159                    if bang == "!" && ident.eq_ignore_ascii_case("important")
1160                );
1161
1162                if is_important {
1163                    let value = if len - 2 == 1 {
1164                        list.iter().next().unwrap().clone()
1165                    } else {
1166                        CssValue::List(list[..len - 2].to_vec())
1167                    };
1168                    (value, true)
1169                } else {
1170                    (value.clone(), false)
1171                }
1172            }
1173            _ => (value.clone(), false),
1174        }
1175    }
1176
1177    pub fn resolve_var(
1178        value: &CssValue,
1179        custom_props: &Properties,
1180        visited: &mut HashSet<CssIdent>,
1181    ) -> Option<CssValue> {
1182        match value {
1183            // `var(--accent)` / `var(--missing, red)` — resolve the custom property
1184            CssValue::Function(name, args) if name == "var" => {
1185                let var_name = match args.first().and_then(|argument| argument.first()) {
1186                    Some(CssValue::Keyword(name)) => name,
1187                    _ => return None,
1188                };
1189
1190                if !visited.insert(var_name.clone()) {
1191                    return None;
1192                }
1193
1194                let result = if let Some(v) = custom_props.get(var_name.as_str()) {
1195                    Self::resolve_var(&v.value, custom_props, visited)
1196                } else if let Some(fallback) = args.get(1).and_then(|argument| argument.first()) {
1197                    Self::resolve_var(fallback, custom_props, visited)
1198                } else {
1199                    None
1200                };
1201
1202                visited.remove(var_name);
1203                result
1204            }
1205
1206            // `rgb(var(--r), var(--g), var(--b))` — resolve args independently
1207            CssValue::Function(name, args) => {
1208                let resolved_args = args
1209                    .iter()
1210                    .map(|argument| {
1211                        argument
1212                            .iter()
1213                            .map(|v| Self::resolve_var(v, custom_props, &mut visited.clone()))
1214                            .collect::<Option<Vec<_>>>()
1215                    })
1216                    .collect::<Option<Vec<_>>>()?;
1217                Some(CssValue::Function(name.clone(), resolved_args))
1218            }
1219
1220            // `1px solid var(--color)` — resolve each item independently
1221            CssValue::List(list) => {
1222                let resolved = list
1223                    .iter()
1224                    .map(|v| Self::resolve_var(v, custom_props, &mut visited.clone()))
1225                    .collect::<Option<Vec<_>>>()?;
1226                Some(CssValue::List(resolved))
1227            }
1228
1229            // `10px` / `"hello"` / `#fff` — already concrete, pass through
1230            _ => Some(value.clone()),
1231        }
1232    }
1233}