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