Skip to main content

hjkl_css/
lib.rs

1//! Parser + AST for a CSS subset used to drive declarative UI styling.
2//!
3//! Toolkit-agnostic — produces a `Stylesheet` of `Rule`s plus a `resolve()`
4//! step that yields the property bag for a single node. Pair with an
5//! adapter crate (e.g. `hjkl-css-floem`) to map onto a specific UI
6//! framework's style builder.
7//!
8//! Supported:
9//! - Type selectors (`label`, `row`), class selectors (`.prompt`),
10//!   pseudo-class selectors (`:hover`, `:focus`, `:active`, `:disabled`,
11//!   `:selected`), and combinations of the three on the same simple
12//!   selector.
13//! - Compound selectors with descendant (` `), child (`>`),
14//!   adjacent-sibling (`+`), and general-sibling (`~`) combinators.
15//! - Properties: `color`, `background-color`, `padding`, `margin`,
16//!   `width`, `height`, `display`, `flex-direction`, `flex-grow`,
17//!   `flex-shrink`, `flex-basis`, `align-items`, `justify-content`,
18//!   `gap`, `row-gap`, `column-gap`, `border`, `border-{top,right,bottom,left}`,
19//!   `border-width`, `border-color`, `border-radius`, `outline`,
20//!   `font-family`, `font-size`, `font-weight`, `font-style`,
21//!   `text-align`, `line-height`.
22//! - Values: hex / `rgb()` / `rgba()` / named colors (CSS Level 1 + extras),
23//!   lengths in `px` / `%` / unitless (treated as px), keywords, `auto`,
24//!   unitless numbers, font-family lists, border shorthands.
25
26pub mod ast;
27pub mod error;
28pub mod parse;
29pub mod resolve;
30pub mod value;
31
32pub use ast::{
33    Combinator, Declaration, Node, PseudoClass, Rule, Selector, SimpleSelector, Stylesheet,
34};
35pub use error::ParseError;
36pub use parse::parse;
37pub use resolve::ResolvedStyle;
38pub use value::{Color, Length, SideValue, Value, expand_side_set, expand_sides};
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    fn s(css: &str) -> Stylesheet {
45        parse(css).unwrap()
46    }
47
48    fn n<'a>(element: &'a str, classes: &'a [&'a str]) -> Node<'a> {
49        Node { element, classes }
50    }
51
52    #[test]
53    fn parses_type_selector_and_one_color_prop() {
54        let sheet = s("label { color: #fff; }");
55        assert_eq!(sheet.rules.len(), 1);
56        assert_eq!(
57            sheet.rules[0].selectors[0].parts[0].element.as_deref(),
58            Some("label")
59        );
60        let resolved = sheet.resolve(&n("label", &[]), &[], &[], None);
61        assert_eq!(
62            resolved.get("color"),
63            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
64        );
65    }
66
67    #[test]
68    fn class_selector_filters() {
69        let sheet = s(".prompt { color: #f00; }");
70        let hit = sheet.resolve(&n("label", &["prompt"]), &[], &[], None);
71        let miss = sheet.resolve(&n("label", &[]), &[], &[], None);
72        assert!(hit.get("color").is_some());
73        assert!(miss.is_empty());
74    }
75
76    #[test]
77    fn pseudo_class_only_applies_in_state() {
78        let sheet = s(".row { color: #aaa; } .row:hover { color: #fff; }");
79        let base = sheet.resolve(&n("row", &["row"]), &[], &[], None);
80        let hover = sheet.resolve(&n("row", &["row"]), &[], &[], Some(PseudoClass::Hover));
81        assert_eq!(
82            base.get("color"),
83            Some(&Value::Color(Color::rgb(0xaa, 0xaa, 0xaa)))
84        );
85        assert_eq!(
86            hover.get("color"),
87            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
88        );
89    }
90
91    #[test]
92    fn padding_shorthand_one_value() {
93        let sheet = s("button { padding: 10px; }");
94        let resolved = sheet.resolve(&n("button", &[]), &[], &[], None);
95        let Value::LengthSet(set) = resolved.get("padding").unwrap() else {
96            panic!("expected LengthSet");
97        };
98        assert_eq!(set, &vec![Length::Px(10.0)]);
99        let expanded = expand_sides(set).unwrap();
100        assert_eq!(expanded, [Length::Px(10.0); 4]);
101    }
102
103    #[test]
104    fn padding_shorthand_two_values_top_right() {
105        let sheet = s("button { padding: 10px 20px; }");
106        let r = sheet.resolve(&n("button", &[]), &[], &[], None);
107        let Value::LengthSet(set) = r.get("padding").unwrap() else {
108            unreachable!()
109        };
110        let exp = expand_sides(set).unwrap();
111        assert_eq!(
112            exp,
113            [
114                Length::Px(10.0),
115                Length::Px(20.0),
116                Length::Px(10.0),
117                Length::Px(20.0)
118            ]
119        );
120    }
121
122    #[test]
123    fn cascade_specificity_class_beats_type() {
124        let sheet = s("label { color: #aaa; } .head { color: #fff; }");
125        let r = sheet.resolve(&n("label", &["head"]), &[], &[], None);
126        assert_eq!(
127            r.get("color"),
128            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
129        );
130    }
131
132    #[test]
133    fn cascade_source_order_breaks_ties() {
134        let sheet = s(".a { color: #001; } .a { color: #002; }");
135        let r = sheet.resolve(&n("x", &["a"]), &[], &[], None);
136        assert_eq!(r.get("color"), Some(&Value::Color(Color::rgb(0, 0, 0x22))));
137    }
138
139    #[test]
140    fn rgb_and_rgba_functions() {
141        let sheet = s("x { color: rgb(255, 128, 0); background-color: rgba(0, 0, 0, 0.5); }");
142        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
143        assert_eq!(
144            r.get("color"),
145            Some(&Value::Color(Color::rgb(0xff, 0x80, 0)))
146        );
147        assert_eq!(
148            r.get("background-color"),
149            Some(&Value::Color(Color::rgba(0, 0, 0, 128)))
150        );
151    }
152
153    #[test]
154    fn selector_list_applies_to_all() {
155        let sheet = s(".a, .b { color: #fff; }");
156        assert!(
157            sheet
158                .resolve(&n("x", &["a"]), &[], &[], None)
159                .get("color")
160                .is_some()
161        );
162        assert!(
163            sheet
164                .resolve(&n("x", &["b"]), &[], &[], None)
165                .get("color")
166                .is_some()
167        );
168        assert!(sheet.resolve(&n("x", &["c"]), &[], &[], None).is_empty());
169    }
170
171    #[test]
172    fn unitless_number_parses_as_px() {
173        let sheet = s("x { width: 100; }");
174        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
175        assert_eq!(r.get("width"), Some(&Value::Length(Length::Px(100.0))));
176    }
177
178    #[test]
179    fn percent_length() {
180        let sheet = s("x { width: 50%; }");
181        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
182        assert_eq!(r.get("width"), Some(&Value::Length(Length::Percent(50.0))));
183    }
184
185    #[test]
186    fn keyword_value() {
187        let sheet = s("x { display: flex; }");
188        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
189        assert_eq!(r.get("display"), Some(&Value::Keyword("flex".to_string())));
190    }
191
192    #[test]
193    fn hex_short_form_expands() {
194        let sheet = s("x { color: #abc; }");
195        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
196        assert_eq!(
197            r.get("color"),
198            Some(&Value::Color(Color::rgb(0xaa, 0xbb, 0xcc)))
199        );
200    }
201
202    #[test]
203    fn unknown_pseudo_class_dropped() {
204        // `:nonsense` makes the whole rule malformed → cssparser drops
205        // the rule, the stylesheet ends up empty. Lenient parsing per
206        // CSS spec; previously this returned `Err` from `parse()`.
207        let sheet = parse(":nonsense { color: #fff; }").unwrap();
208        assert!(sheet.rules.is_empty());
209    }
210
211    #[test]
212    fn descendant_combinator_parses() {
213        // `.a .b { … }` must now parse into one rule with a Descendant combinator.
214        let sheet = parse(".a .b { color: #fff; }").unwrap();
215        assert_eq!(sheet.rules.len(), 1);
216        let sel = &sheet.rules[0].selectors[0];
217        assert_eq!(sel.combinators, vec![Combinator::Descendant]);
218        assert_eq!(sel.parts.len(), 2);
219    }
220
221    #[test]
222    fn descendant_combinator_through_comment() {
223        // `.a /* x */ .b` — cssparser emits two whitespace tokens around
224        // the comment. The compound-selector parser must collapse them
225        // before deciding whether what follows is a combinator.
226        for css in [
227            ".a /* x */ .b { color: #fff; }",
228            ".a   /* x */   .b { color: #fff; }",
229            ".a/* x */ .b { color: #fff; }",
230        ] {
231            let sheet = parse(css).unwrap();
232            assert_eq!(sheet.rules.len(), 1, "input: {css}");
233            let sel = &sheet.rules[0].selectors[0];
234            assert_eq!(
235                sel.combinators,
236                vec![Combinator::Descendant],
237                "input: {css}"
238            );
239            assert_eq!(sel.parts.len(), 2, "input: {css}");
240        }
241    }
242
243    #[test]
244    fn pseudo_class_is_case_insensitive() {
245        let sheet = s(".row:HOVER { color: #fff; } .row:Focus { color: #aaa; }");
246        let h = sheet.resolve(&n("row", &["row"]), &[], &[], Some(PseudoClass::Hover));
247        let f = sheet.resolve(&n("row", &["row"]), &[], &[], Some(PseudoClass::Focus));
248        assert_eq!(
249            h.get("color"),
250            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
251        );
252        assert_eq!(
253            f.get("color"),
254            Some(&Value::Color(Color::rgb(0xaa, 0xaa, 0xaa)))
255        );
256    }
257
258    #[test]
259    fn bad_declaration_does_not_drop_neighbours() {
260        // `font: 12px Arial` has no PropertyKind so it routes through the
261        // Unknown branch — but `12px Arial` has two tokens and fails
262        // expect_exhausted. The CSS spec says a malformed declaration must
263        // be skipped, leaving siblings intact.
264        let sheet = s("x { font: 12px Arial; color: #fff; padding: 4px; }");
265        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
266        assert_eq!(
267            r.get("color"),
268            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
269        );
270        let Value::LengthSet(set) = r.get("padding").unwrap() else {
271            unreachable!()
272        };
273        assert_eq!(set, &vec![Length::Px(4.0)]);
274        assert!(r.get("font").is_none(), "font must not have leaked through");
275    }
276
277    #[test]
278    fn important_flag_is_tolerated() {
279        // Smoke test that a single `!important` declaration resolves
280        // cleanly; the cascade behaviour against competing rules lives in
281        // `important_beats_higher_specificity` and
282        // `important_loses_to_later_important` below.
283        let sheet = s("x { color: #fff !important; }");
284        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
285        assert_eq!(
286            r.get("color"),
287            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
288        );
289    }
290
291    #[test]
292    fn at_rules_are_silently_skipped() {
293        // `@charset` (statement at-rule) and `@media` (block at-rule)
294        // must not abort the surrounding stylesheet.
295        let sheet = s(r#"
296            @charset "utf-8";
297            @media (min-width: 100px) { .ignored { color: #000; } }
298            .visible { color: #fff; }
299        "#);
300        let v = sheet.resolve(&n("x", &["visible"]), &[], &[], None);
301        assert_eq!(
302            v.get("color"),
303            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
304        );
305        let i = sheet.resolve(&n("x", &["ignored"]), &[], &[], None);
306        assert!(i.is_empty(), "@media block contents must not leak");
307    }
308
309    #[test]
310    fn descendant_combinator_all_shapes_parse() {
311        // All shapes that were previously dropped now parse into one rule each
312        // with the correct combinator.
313        for css in [
314            "label span { color: #fff; }",
315            "label .b { color: #fff; }",
316            "label :hover { color: #fff; }",
317            ".a label { color: #fff; }",
318            ":hover label { color: #fff; }",
319        ] {
320            let sheet = parse(css).unwrap();
321            assert_eq!(
322                sheet.rules.len(),
323                1,
324                "descendant combinator must parse to one rule: {css}"
325            );
326            assert_eq!(
327                sheet.rules[0].selectors[0].combinators,
328                vec![Combinator::Descendant],
329                "expected Descendant combinator: {css}"
330            );
331        }
332    }
333
334    #[test]
335    fn important_flag_surfaces_on_declaration() {
336        let sheet = parse(".a { color: #fff !important; padding: 4px; }").unwrap();
337        let decls = &sheet.rules[0].declarations;
338        let color = decls.iter().find(|d| d.property == "color").unwrap();
339        let padding = decls.iter().find(|d| d.property == "padding").unwrap();
340        assert!(color.important, "!important must survive on the AST");
341        assert!(!padding.important);
342    }
343
344    #[test]
345    fn important_beats_higher_specificity() {
346        // `.important !important` must override `.specific:hover` which
347        // has higher specificity (20 vs 10) — important wins regardless.
348        let sheet = s(".important { color: #fff !important; } \
349                       .specific:hover { color: #000; }");
350        let r = sheet.resolve(
351            &n("x", &["important", "specific"]),
352            &[],
353            &[],
354            Some(PseudoClass::Hover),
355        );
356        assert_eq!(
357            r.get("color"),
358            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
359        );
360    }
361
362    #[test]
363    fn important_loses_to_later_important() {
364        // Within the !important group, source order still applies — the
365        // later !important wins on equal specificity.
366        let sheet = s(".a { color: #001 !important; } .a { color: #002 !important; }");
367        let r = sheet.resolve(&n("x", &["a"]), &[], &[], None);
368        assert_eq!(r.get("color"), Some(&Value::Color(Color::rgb(0, 0, 0x22))));
369    }
370
371    #[test]
372    fn malformed_rule_does_not_drop_neighbours() {
373        // The `:nonsense` selector is invalid → cssparser drops that whole
374        // rule, but the second rule must still land in the stylesheet.
375        let sheet = s(":nonsense { color: #000; } \
376                       .good { color: #fff; }");
377        let r = sheet.resolve(&n("x", &["good"]), &[], &[], None);
378        assert_eq!(
379            r.get("color"),
380            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
381        );
382    }
383
384    #[test]
385    fn unknown_color_name_rejected_for_color_property() {
386        // Previously this would silently parse as Value::Keyword and leak
387        // through. Property-aware value parsing rejects it as a bad
388        // declaration, which the cascade then skips.
389        let sheet = s("x { color: nonsense; }");
390        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
391        assert!(r.get("color").is_none());
392    }
393
394    // ---- Phase 2 tests -------------------------------------------------------
395
396    // Layout
397
398    #[test]
399    fn display_flex() {
400        let r = s("x { display: flex; }").resolve(&n("x", &[]), &[], &[], None);
401        assert_eq!(r.get("display"), Some(&Value::Keyword("flex".into())));
402    }
403
404    #[test]
405    fn display_unknown_rejected() {
406        let r = s("x { display: inline; }").resolve(&n("x", &[]), &[], &[], None);
407        assert!(r.get("display").is_none());
408    }
409
410    #[test]
411    fn flex_direction() {
412        let r = s("x { flex-direction: column; }").resolve(&n("x", &[]), &[], &[], None);
413        assert_eq!(
414            r.get("flex-direction"),
415            Some(&Value::Keyword("column".into()))
416        );
417    }
418
419    #[test]
420    fn flex_grow_and_shrink() {
421        let r = s("x { flex-grow: 2; flex-shrink: 0; }").resolve(&n("x", &[]), &[], &[], None);
422        assert_eq!(r.get("flex-grow"), Some(&Value::Number(2.0)));
423        assert_eq!(r.get("flex-shrink"), Some(&Value::Number(0.0)));
424    }
425
426    #[test]
427    fn flex_grow_negative_dropped() {
428        // CSS spec: flex-grow / flex-shrink must be >= 0. The bad
429        // declaration is dropped per the standard cascade rules.
430        let r = s("x { flex-grow: -1; }").resolve(&n("x", &[]), &[], &[], None);
431        assert!(r.get("flex-grow").is_none());
432    }
433
434    #[test]
435    fn flex_basis_length() {
436        let r = s("x { flex-basis: 200px; }").resolve(&n("x", &[]), &[], &[], None);
437        assert_eq!(r.get("flex-basis"), Some(&Value::Length(Length::Px(200.0))));
438    }
439
440    #[test]
441    fn flex_basis_auto() {
442        let r = s("x { flex-basis: auto; }").resolve(&n("x", &[]), &[], &[], None);
443        assert_eq!(r.get("flex-basis"), Some(&Value::Auto));
444    }
445
446    #[test]
447    fn align_items() {
448        let r = s("x { align-items: center; }").resolve(&n("x", &[]), &[], &[], None);
449        assert_eq!(r.get("align-items"), Some(&Value::Keyword("center".into())));
450    }
451
452    #[test]
453    fn justify_content() {
454        let r = s("x { justify-content: space-between; }").resolve(&n("x", &[]), &[], &[], None);
455        assert_eq!(
456            r.get("justify-content"),
457            Some(&Value::Keyword("space-between".into()))
458        );
459    }
460
461    #[test]
462    fn gap() {
463        let r = s("x { gap: 8px; row-gap: 4px; column-gap: 2px; }").resolve(
464            &n("x", &[]),
465            &[],
466            &[],
467            None,
468        );
469        assert_eq!(r.get("gap"), Some(&Value::Length(Length::Px(8.0))));
470        assert_eq!(r.get("row-gap"), Some(&Value::Length(Length::Px(4.0))));
471        assert_eq!(r.get("column-gap"), Some(&Value::Length(Length::Px(2.0))));
472    }
473
474    // Box — border
475
476    #[test]
477    fn border_shorthand() {
478        let r = s("x { border: 1px solid #fff; }").resolve(&n("x", &[]), &[], &[], None);
479        assert_eq!(
480            r.get("border"),
481            Some(&Value::Border {
482                width: Length::Px(1.0),
483                color: Color::rgb(0xff, 0xff, 0xff),
484            })
485        );
486    }
487
488    #[test]
489    fn border_out_of_order_tokens() {
490        let r = s("x { border: solid 1px #fff; }").resolve(&n("x", &[]), &[], &[], None);
491        assert_eq!(
492            r.get("border"),
493            Some(&Value::Border {
494                width: Length::Px(1.0),
495                color: Color::rgb(0xff, 0xff, 0xff),
496            })
497        );
498    }
499
500    #[test]
501    fn border_none_is_transparent_zero() {
502        // The most common CSS reset; the round-2 review caught this being
503        // rejected. `border: none` must resolve to a structurally present
504        // but visually invisible border.
505        let r = s("x { border: none; }").resolve(&n("x", &[]), &[], &[], None);
506        assert_eq!(
507            r.get("border"),
508            Some(&Value::Border {
509                width: Length::Px(0.0),
510                color: Color::rgba(0, 0, 0, 0),
511            })
512        );
513    }
514
515    #[test]
516    fn border_no_color_rejected() {
517        // `border: 1px solid` — missing color → declaration dropped.
518        let r = s("x { border: 1px solid; }").resolve(&n("x", &[]), &[], &[], None);
519        assert!(r.get("border").is_none());
520    }
521
522    #[test]
523    fn border_unknown_style_keyword_ignored() {
524        // `dashed`/`dotted`/`double` etc. parse without dropping the
525        // declaration — floem has no border-style model, so the style
526        // token is accepted and ignored, same as `solid`.
527        for css in [
528            "x { border: 2px dashed #f00; }",
529            "x { border: 2px dotted #f00; }",
530            "x { border: 2px double #f00; }",
531            "x { border: 2px groove #f00; }",
532        ] {
533            let r = s(css).resolve(&n("x", &[]), &[], &[], None);
534            assert_eq!(
535                r.get("border"),
536                Some(&Value::Border {
537                    width: Length::Px(2.0),
538                    color: Color::rgb(0xff, 0x00, 0x00),
539                }),
540                "input: {css}"
541            );
542        }
543    }
544
545    #[test]
546    fn border_side() {
547        let r = s("x { border-top: 2px solid red; }").resolve(&n("x", &[]), &[], &[], None);
548        assert_eq!(
549            r.get("border-top"),
550            Some(&Value::Border {
551                width: Length::Px(2.0),
552                color: Color::rgb(0xff, 0x00, 0x00),
553            })
554        );
555    }
556
557    #[test]
558    fn border_width_and_color() {
559        let r =
560            s("x { border-width: 3px; border-color: blue; }").resolve(&n("x", &[]), &[], &[], None);
561        assert_eq!(
562            r.get("border-width"),
563            Some(&Value::LengthSet(vec![Length::Px(3.0)]))
564        );
565        assert_eq!(
566            r.get("border-color"),
567            Some(&Value::Color(Color::rgb(0x00, 0x00, 0xff)))
568        );
569    }
570
571    #[test]
572    fn border_width_four_side_shorthand() {
573        let r = s("x { border-width: 1px 2px 3px 4px; }").resolve(&n("x", &[]), &[], &[], None);
574        let Value::LengthSet(set) = r.get("border-width").unwrap() else {
575            panic!("expected LengthSet");
576        };
577        assert_eq!(
578            set,
579            &vec![
580                Length::Px(1.0),
581                Length::Px(2.0),
582                Length::Px(3.0),
583                Length::Px(4.0),
584            ]
585        );
586    }
587
588    #[test]
589    fn border_radius() {
590        let r = s("x { border-radius: 4px 8px; }").resolve(&n("x", &[]), &[], &[], None);
591        let Value::LengthSet(set) = r.get("border-radius").unwrap() else {
592            panic!("expected LengthSet");
593        };
594        assert_eq!(set, &vec![Length::Px(4.0), Length::Px(8.0)]);
595    }
596
597    #[test]
598    fn outline_shorthand() {
599        let r = s("x { outline: 1px solid #000; }").resolve(&n("x", &[]), &[], &[], None);
600        assert_eq!(
601            r.get("outline"),
602            Some(&Value::Border {
603                width: Length::Px(1.0),
604                color: Color::rgb(0x00, 0x00, 0x00),
605            })
606        );
607    }
608
609    // Sizing — auto
610
611    #[test]
612    fn width_auto() {
613        let r = s("x { width: auto; }").resolve(&n("x", &[]), &[], &[], None);
614        assert_eq!(r.get("width"), Some(&Value::Auto));
615    }
616
617    #[test]
618    fn height_auto() {
619        let r = s("x { height: auto; }").resolve(&n("x", &[]), &[], &[], None);
620        assert_eq!(r.get("height"), Some(&Value::Auto));
621    }
622
623    #[test]
624    fn margin_auto() {
625        let r = s("x { margin: auto; }").resolve(&n("x", &[]), &[], &[], None);
626        assert_eq!(r.get("margin"), Some(&Value::Auto));
627    }
628
629    #[test]
630    fn margin_mixed_auto() {
631        // `margin: 4px auto` — mixed → SideSet
632        let r = s("x { margin: 4px auto; }").resolve(&n("x", &[]), &[], &[], None);
633        let Value::SideSet(sides) = r.get("margin").unwrap() else {
634            panic!("expected SideSet");
635        };
636        assert_eq!(sides[0], SideValue::Length(Length::Px(4.0)));
637        assert_eq!(sides[1], SideValue::Auto);
638    }
639
640    #[test]
641    fn margin_all_lengths_downcasts_to_length_set() {
642        // All-length margin → LengthSet (backward compat with adapters).
643        let r = s("x { margin: 4px 8px; }").resolve(&n("x", &[]), &[], &[], None);
644        assert!(
645            matches!(r.get("margin"), Some(Value::LengthSet(_))),
646            "expected LengthSet"
647        );
648    }
649
650    #[test]
651    fn expand_side_set_mirrors_css_shorthand() {
652        let one = vec![SideValue::Auto];
653        let two = vec![SideValue::Length(Length::Px(4.0)), SideValue::Auto];
654        let three = vec![
655            SideValue::Length(Length::Px(1.0)),
656            SideValue::Auto,
657            SideValue::Length(Length::Px(3.0)),
658        ];
659        let four = vec![
660            SideValue::Length(Length::Px(1.0)),
661            SideValue::Length(Length::Px(2.0)),
662            SideValue::Length(Length::Px(3.0)),
663            SideValue::Length(Length::Px(4.0)),
664        ];
665        assert_eq!(expand_side_set(&one).unwrap(), [SideValue::Auto; 4]);
666        let exp_two = expand_side_set(&two).unwrap();
667        assert_eq!(exp_two[0], SideValue::Length(Length::Px(4.0)));
668        assert_eq!(exp_two[1], SideValue::Auto);
669        assert_eq!(exp_two[2], SideValue::Length(Length::Px(4.0)));
670        assert_eq!(exp_two[3], SideValue::Auto);
671        let exp_three = expand_side_set(&three).unwrap();
672        assert_eq!(
673            exp_three[1], exp_three[3],
674            "right == left when 3 values given"
675        );
676        let exp_four = expand_side_set(&four).unwrap();
677        assert_eq!(exp_four[3], SideValue::Length(Length::Px(4.0)));
678        // Out-of-range returns None.
679        assert!(expand_side_set(&[]).is_none());
680        assert!(expand_side_set(&[SideValue::Auto; 5]).is_none());
681    }
682
683    // Typography
684
685    #[test]
686    fn font_family_quoted_and_keyword() {
687        let r = s(r#"x { font-family: "Hack Nerd Font", monospace; }"#).resolve(
688            &n("x", &[]),
689            &[],
690            &[],
691            None,
692        );
693        let Value::FontFamilyList(list) = r.get("font-family").unwrap() else {
694            panic!("expected FontFamilyList");
695        };
696        assert_eq!(
697            list,
698            &vec!["Hack Nerd Font".to_string(), "monospace".to_string()]
699        );
700    }
701
702    #[test]
703    fn font_family_single_ident() {
704        let r = s("x { font-family: monospace; }").resolve(&n("x", &[]), &[], &[], None);
705        let Value::FontFamilyList(list) = r.get("font-family").unwrap() else {
706            panic!("expected FontFamilyList");
707        };
708        assert_eq!(list, &vec!["monospace".to_string()]);
709    }
710
711    #[test]
712    fn font_family_trailing_comma_dropped() {
713        // `font-family: "Hack",` is malformed CSS; the bad declaration is
714        // dropped and the stylesheet survives.
715        let r =
716            s(r#"x { font-family: "Hack",; color: #fff; }"#).resolve(&n("x", &[]), &[], &[], None);
717        assert!(r.get("font-family").is_none());
718        assert_eq!(
719            r.get("color"),
720            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
721        );
722    }
723
724    #[test]
725    fn font_size() {
726        let r = s("x { font-size: 16px; }").resolve(&n("x", &[]), &[], &[], None);
727        assert_eq!(r.get("font-size"), Some(&Value::Length(Length::Px(16.0))));
728    }
729
730    #[test]
731    fn font_weight_numeric() {
732        let r = s("x { font-weight: 350; }").resolve(&n("x", &[]), &[], &[], None);
733        assert_eq!(r.get("font-weight"), Some(&Value::Number(350.0)));
734    }
735
736    #[test]
737    fn font_weight_bold_keyword() {
738        let r = s("x { font-weight: bold; }").resolve(&n("x", &[]), &[], &[], None);
739        assert_eq!(r.get("font-weight"), Some(&Value::Keyword("bold".into())));
740    }
741
742    #[test]
743    fn font_weight_bolder_rejected() {
744        let r = s("x { font-weight: bolder; }").resolve(&n("x", &[]), &[], &[], None);
745        assert!(r.get("font-weight").is_none());
746    }
747
748    #[test]
749    fn font_style() {
750        let r = s("x { font-style: italic; }").resolve(&n("x", &[]), &[], &[], None);
751        assert_eq!(r.get("font-style"), Some(&Value::Keyword("italic".into())));
752    }
753
754    #[test]
755    fn text_align() {
756        let r = s("x { text-align: center; }").resolve(&n("x", &[]), &[], &[], None);
757        assert_eq!(r.get("text-align"), Some(&Value::Keyword("center".into())));
758    }
759
760    #[test]
761    fn line_height_unitless() {
762        let r = s("x { line-height: 1.5; }").resolve(&n("x", &[]), &[], &[], None);
763        assert_eq!(r.get("line-height"), Some(&Value::Number(1.5)));
764    }
765
766    #[test]
767    fn line_height_px() {
768        let r = s("x { line-height: 24px; }").resolve(&n("x", &[]), &[], &[], None);
769        assert_eq!(r.get("line-height"), Some(&Value::Length(Length::Px(24.0))));
770    }
771
772    // Issue #3 — font-style: oblique
773
774    #[test]
775    fn font_style_oblique_accepted() {
776        let r = s("x { font-style: oblique; }").resolve(&n("x", &[]), &[], &[], None);
777        assert_eq!(r.get("font-style"), Some(&Value::Keyword("oblique".into())));
778    }
779
780    #[test]
781    fn font_style_unknown_keyword_dropped() {
782        // `weird` is not in the allowed list → declaration dropped, rule survives.
783        let r = s("x { font-style: weird; color: #fff; }").resolve(&n("x", &[]), &[], &[], None);
784        assert!(r.get("font-style").is_none());
785        assert_eq!(
786            r.get("color"),
787            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
788        );
789    }
790
791    // Issue #4 — per-side border-{side}-color longhands
792
793    #[test]
794    fn border_top_color_resolves() {
795        for side in ["top", "right", "bottom", "left"] {
796            let css = format!("x {{ border-{side}-color: red; }}");
797            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
798            let prop = format!("border-{side}-color");
799            assert_eq!(
800                r.get(&prop),
801                Some(&Value::Color(Color::rgb(0xff, 0x00, 0x00))),
802                "border-{side}-color must resolve as Color"
803            );
804        }
805    }
806
807    // Issue #5 — font-weight range clamping
808
809    #[test]
810    fn font_weight_out_of_range_dropped() {
811        // Values outside 1..=1000 or with fractional parts are invalid.
812        for bad in ["9999", "-100", "0.5", "0"] {
813            let css = format!("x {{ font-weight: {bad}; color: #fff; }}");
814            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
815            assert!(
816                r.get("font-weight").is_none(),
817                "font-weight: {bad} should be dropped"
818            );
819            // sibling declaration must survive
820            assert!(
821                r.get("color").is_some(),
822                "color must survive bad font-weight: {bad}"
823            );
824        }
825        // In-range integer must still pass.
826        let r = s("x { font-weight: 700; }").resolve(&n("x", &[]), &[], &[], None);
827        assert_eq!(r.get("font-weight"), Some(&Value::Number(700.0)));
828        // Keyword forms must still pass.
829        for kw in ["bold", "normal"] {
830            let css = format!("x {{ font-weight: {kw}; }}");
831            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
832            assert_eq!(
833                r.get("font-weight"),
834                Some(&Value::Keyword(kw.into())),
835                "font-weight: {kw} keyword must resolve"
836            );
837        }
838        // Unknown keywords (e.g. `bolder`, `lighter`) are rejected.
839        let r = s("x { font-weight: bolder; color: #fff; }").resolve(&n("x", &[]), &[], &[], None);
840        assert!(
841            r.get("font-weight").is_none(),
842            "unsupported font-weight keyword must be dropped"
843        );
844        // Boundary integers — both ends of the CSS spec range.
845        for ok in ["1", "1000"] {
846            let css = format!("x {{ font-weight: {ok}; }}");
847            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
848            assert_eq!(
849                r.get("font-weight"),
850                Some(&Value::Number(ok.parse().unwrap())),
851                "font-weight: {ok} boundary value must resolve"
852            );
853        }
854    }
855
856    // Named color expansion
857
858    #[test]
859    fn named_colors_level1() {
860        let cases: &[(&str, Color)] = &[
861            ("silver", Color::rgb(0xc0, 0xc0, 0xc0)),
862            ("maroon", Color::rgb(0x80, 0x00, 0x00)),
863            ("purple", Color::rgb(0x80, 0x00, 0x80)),
864            ("fuchsia", Color::rgb(0xff, 0x00, 0xff)),
865            ("lime", Color::rgb(0x00, 0xff, 0x00)),
866            ("olive", Color::rgb(0x80, 0x80, 0x00)),
867            ("yellow", Color::rgb(0xff, 0xff, 0x00)),
868            ("navy", Color::rgb(0x00, 0x00, 0x80)),
869            ("teal", Color::rgb(0x00, 0x80, 0x80)),
870            ("aqua", Color::rgb(0x00, 0xff, 0xff)),
871        ];
872        for (name, expected) in cases {
873            let css = format!("x {{ color: {name}; }}");
874            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
875            assert_eq!(
876                r.get("color"),
877                Some(&Value::Color(*expected)),
878                "named color `{name}` mismatch"
879            );
880        }
881    }
882
883    #[test]
884    fn named_colors_extras() {
885        let cases: &[(&str, Color)] = &[
886            ("gray", Color::rgb(0x80, 0x80, 0x80)),
887            ("grey", Color::rgb(0x80, 0x80, 0x80)),
888            ("cyan", Color::rgb(0x00, 0xff, 0xff)),
889            ("magenta", Color::rgb(0xff, 0x00, 0xff)),
890            ("orange", Color::rgb(0xff, 0xa5, 0x00)),
891            ("brown", Color::rgb(0xa5, 0x2a, 0x2a)),
892            ("pink", Color::rgb(0xff, 0xc0, 0xcb)),
893        ];
894        for (name, expected) in cases {
895            let css = format!("x {{ color: {name}; }}");
896            let r = s(&css).resolve(&n("x", &[]), &[], &[], None);
897            assert_eq!(
898                r.get("color"),
899                Some(&Value::Color(*expected)),
900                "named color `{name}` mismatch"
901            );
902        }
903    }
904
905    // ---- Combinator tests ----------------------------------------------------
906
907    #[test]
908    fn descendant_no_match_without_ancestor() {
909        // `.outer .target { color: #fff; }` — target has no `.outer` ancestor.
910        let sheet = s(".outer .target { color: #fff; }");
911        let r = sheet.resolve(&n("div", &["target"]), &[], &[], None);
912        assert!(r.get("color").is_none());
913    }
914
915    #[test]
916    fn descendant_match_with_ancestor() {
917        let sheet = s(".outer .target { color: #fff; }");
918        let ancestors = [n("div", &["outer"])];
919        let r = sheet.resolve(&n("div", &["target"]), &ancestors, &[], None);
920        assert_eq!(
921            r.get("color"),
922            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
923        );
924    }
925
926    #[test]
927    fn descendant_match_with_distant_ancestor() {
928        // `.outer` is a grandparent — Descendant must still match.
929        let sheet = s(".outer .target { color: #fff; }");
930        let ancestors = [n("root", &[]), n("div", &["outer"]), n("div", &["mid"])];
931        let r = sheet.resolve(&n("span", &["target"]), &ancestors, &[], None);
932        assert_eq!(
933            r.get("color"),
934            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
935        );
936    }
937
938    #[test]
939    fn child_match_immediate_parent() {
940        let sheet = s(".outer > .target { color: #fff; }");
941        let ancestors = [n("div", &["outer"])];
942        let r = sheet.resolve(&n("span", &["target"]), &ancestors, &[], None);
943        assert_eq!(
944            r.get("color"),
945            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
946        );
947    }
948
949    #[test]
950    fn child_no_match_grandparent() {
951        // `.outer > .target` — `.outer` is two levels up, not direct parent.
952        let sheet = s(".outer > .target { color: #fff; }");
953        let ancestors = [n("div", &["outer"]), n("div", &["mid"])];
954        let r = sheet.resolve(&n("span", &["target"]), &ancestors, &[], None);
955        assert!(r.get("color").is_none());
956    }
957
958    #[test]
959    fn adjacent_sibling_match() {
960        let sheet = s(".prev + .target { color: #fff; }");
961        let prev_siblings = [n("div", &["prev"])];
962        let r = sheet.resolve(&n("span", &["target"]), &[], &prev_siblings, None);
963        assert_eq!(
964            r.get("color"),
965            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
966        );
967    }
968
969    #[test]
970    fn adjacent_sibling_no_match_non_immediate() {
971        // `.prev + .target` — `.prev` is not the *immediately* preceding sibling.
972        let sheet = s(".prev + .target { color: #fff; }");
973        let prev_siblings = [n("div", &["prev"]), n("div", &["between"])];
974        let r = sheet.resolve(&n("span", &["target"]), &[], &prev_siblings, None);
975        assert!(r.get("color").is_none());
976    }
977
978    #[test]
979    fn general_sibling_match_any() {
980        let sheet = s(".prev ~ .target { color: #fff; }");
981        // `.prev` is not the immediately preceding sibling but still matches `~`.
982        let prev_siblings = [n("div", &["prev"]), n("div", &["between"])];
983        let r = sheet.resolve(&n("span", &["target"]), &[], &prev_siblings, None);
984        assert_eq!(
985            r.get("color"),
986            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
987        );
988    }
989
990    #[test]
991    fn general_sibling_no_match_without_sibling() {
992        let sheet = s(".prev ~ .target { color: #fff; }");
993        let r = sheet.resolve(&n("span", &["target"]), &[], &[], None);
994        assert!(r.get("color").is_none());
995    }
996
997    #[test]
998    fn chained_adjacent_siblings_match() {
999        // `.a + .b + .target` against target with prev_siblings = [a, b].
1000        // Round-2 review caught this false-negativing — the matcher
1001        // wasn't shrinking `prev_siblings` across consecutive `+`
1002        // combinators, so the second hop always saw `b` again instead
1003        // of `a`.
1004        let sheet = s(".a + .b + .target { color: #fff; }");
1005        let prev_siblings = [n("div", &["a"]), n("div", &["b"])];
1006        let r = sheet.resolve(&n("span", &["target"]), &[], &prev_siblings, None);
1007        assert_eq!(
1008            r.get("color"),
1009            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
1010        );
1011    }
1012
1013    #[test]
1014    fn chained_general_siblings_match() {
1015        // `.a ~ .b ~ .target` — `.b` must follow `.a` somewhere in the
1016        // prev-sibling list, then `.target` follows `.b`.
1017        let sheet = s(".a ~ .b ~ .target { color: #fff; }");
1018        let prev_siblings = [n("div", &["a"]), n("div", &["between"]), n("div", &["b"])];
1019        let r = sheet.resolve(&n("span", &["target"]), &[], &prev_siblings, None);
1020        assert_eq!(
1021            r.get("color"),
1022            Some(&Value::Color(Color::rgb(0xff, 0xff, 0xff)))
1023        );
1024    }
1025
1026    #[test]
1027    fn specificity_sums_across_parts() {
1028        // `.a .b.c` — three classes total → specificity 30, not 20.
1029        let sheet = s(".a .b.c { color: #fff; }");
1030        let sel = &sheet.rules[0].selectors[0];
1031        assert_eq!(sel.specificity(), 30);
1032    }
1033
1034    #[test]
1035    fn pseudo_on_ancestor_part_does_not_match() {
1036        // `.outer:hover > .target` — pseudo applies only to the subject.
1037        // The ancestor part `.outer:hover` is matched without state (state=None),
1038        // so `:hover` on it never fires regardless of the target's state.
1039        let sheet = s(".outer:hover > .target { color: #fff; }");
1040        let ancestors = [n("div", &["outer"])];
1041        // Even when the target is in hover state, the rule must not match
1042        // because the ancestor `.outer` is matched with state=None.
1043        let r = sheet.resolve(
1044            &n("span", &["target"]),
1045            &ancestors,
1046            &[],
1047            Some(PseudoClass::Hover),
1048        );
1049        assert!(r.get("color").is_none());
1050    }
1051
1052    #[test]
1053    fn explicit_child_combinator_with_whitespace() {
1054        // `.a > .b` with spaces around `>` must parse as Child.
1055        let sheet = s(".a > .b { color: #fff; }");
1056        let sel = &sheet.rules[0].selectors[0];
1057        assert_eq!(sel.combinators, vec![Combinator::Child]);
1058    }
1059
1060    #[test]
1061    fn explicit_adjacent_sibling_combinator() {
1062        let sheet = s(".a + .b { color: #fff; }");
1063        let sel = &sheet.rules[0].selectors[0];
1064        assert_eq!(sel.combinators, vec![Combinator::AdjacentSibling]);
1065    }
1066
1067    #[test]
1068    fn explicit_general_sibling_combinator() {
1069        let sheet = s(".a ~ .b { color: #fff; }");
1070        let sel = &sheet.rules[0].selectors[0];
1071        assert_eq!(sel.combinators, vec![Combinator::GeneralSibling]);
1072    }
1073
1074    // ---- Source-order iter tests ---------------------------------------------
1075
1076    #[test]
1077    fn iter_returns_source_order() {
1078        // Two properties from different rules: iter must yield them in
1079        // ascending rule_idx order (color rule 0 before background-color
1080        // rule 1), not alphabetical order (which would also be color first
1081        // here — use a stronger example with reversed alpha order).
1082        //
1083        // "width" (w) sorts after "color" (c) alphabetically, but rule 0
1084        // sets width and rule 1 sets color, so source order is: width, color.
1085        let sheet = s(".a { width: 10px; } .a { color: #001; }");
1086        let r = sheet.resolve(&n("x", &["a"]), &[], &[], None);
1087        let keys: Vec<&str> = r.iter().map(|(k, _)| k).collect();
1088        let width_pos = keys.iter().position(|&k| k == "width").unwrap();
1089        let color_pos = keys.iter().position(|&k| k == "color").unwrap();
1090        assert!(
1091            width_pos < color_pos,
1092            "width (rule 0) must come before color (rule 1): got {keys:?}"
1093        );
1094    }
1095
1096    #[test]
1097    fn shorthand_then_longhand_source_order() {
1098        // Case A: border (rule 0) then border-color (rule 1).
1099        // iter must yield border before border-color.
1100        let sheet_a = s("x { border: 1px solid red; } x { border-color: blue; }");
1101        let r_a = sheet_a.resolve(&n("x", &[]), &[], &[], None);
1102        let keys_a: Vec<&str> = r_a.iter().map(|(k, _)| k).collect();
1103        let border_pos = keys_a.iter().position(|&k| k == "border").unwrap();
1104        let bc_pos = keys_a.iter().position(|&k| k == "border-color").unwrap();
1105        assert!(
1106            border_pos < bc_pos,
1107            "border (rule 0) must come before border-color (rule 1): got {keys_a:?}"
1108        );
1109
1110        // Case B: reversed — border-color (rule 0) then border (rule 1).
1111        // iter must yield border-color before border.
1112        let sheet_b = s("x { border-color: blue; } x { border: 1px solid red; }");
1113        let r_b = sheet_b.resolve(&n("x", &[]), &[], &[], None);
1114        let keys_b: Vec<&str> = r_b.iter().map(|(k, _)| k).collect();
1115        let border_pos_b = keys_b.iter().position(|&k| k == "border").unwrap();
1116        let bc_pos_b = keys_b.iter().position(|&k| k == "border-color").unwrap();
1117        assert!(
1118            bc_pos_b < border_pos_b,
1119            "border-color (rule 0) must come before border (rule 1): got {keys_b:?}"
1120        );
1121    }
1122
1123    #[test]
1124    fn intra_rule_source_order() {
1125        // Two properties declared in the same rule block. Tie-break must
1126        // be the in-block declaration position, NOT alphabetical.
1127        // `border-color` declared first, `border` declared second → iter
1128        // yields border-color before border. An adapter applying these
1129        // sequentially ends up with the `border` shorthand's color, which
1130        // is the CSS-correct late-wins semantic.
1131        let sheet = s("x { border-color: blue; border: 1px solid red; }");
1132        let r = sheet.resolve(&n("x", &[]), &[], &[], None);
1133        let keys: Vec<&str> = r.iter().map(|(k, _)| k).collect();
1134        let bc_pos = keys.iter().position(|&k| k == "border-color").unwrap();
1135        let border_pos = keys.iter().position(|&k| k == "border").unwrap();
1136        assert!(
1137            bc_pos < border_pos,
1138            "border-color (decl 0) must come before border (decl 1) within the same rule: got {keys:?}"
1139        );
1140
1141        // Reversed: border (decl 0), border-color (decl 1) → iter yields
1142        // border before border-color.
1143        let sheet_rev = s("x { border: 1px solid red; border-color: blue; }");
1144        let r_rev = sheet_rev.resolve(&n("x", &[]), &[], &[], None);
1145        let keys_rev: Vec<&str> = r_rev.iter().map(|(k, _)| k).collect();
1146        let border_pos_rev = keys_rev.iter().position(|&k| k == "border").unwrap();
1147        let bc_pos_rev = keys_rev.iter().position(|&k| k == "border-color").unwrap();
1148        assert!(
1149            border_pos_rev < bc_pos_rev,
1150            "border (decl 0) must come before border-color (decl 1) within the same rule: got {keys_rev:?}"
1151        );
1152    }
1153}