Skip to main content

hjkl_css/
parse.rs

1//! Convert a CSS-subset string to a [`Stylesheet`]. Backed by `cssparser`'s
2//! tokenizer + `StyleSheetParser`. Compound selectors with descendant (` `),
3//! child (`>`), adjacent-sibling (`+`), and general-sibling (`~`) combinators
4//! are supported.
5
6use cssparser::{
7    AtRuleParser, CowRcStr, DeclarationParser, ParseError as CssParseError, Parser, ParserInput,
8    ParserState, QualifiedRuleParser, RuleBodyItemParser, RuleBodyParser, StyleSheetParser, Token,
9    match_ignore_ascii_case,
10};
11
12use crate::ast::{
13    Combinator, Declaration, PseudoClass, Rule, Selector, SimpleSelector, Stylesheet,
14};
15use crate::error::{ParseError, ParseErrorOwned};
16use crate::value::{Color, Length, SideValue, Value};
17
18pub fn parse(input: &str) -> Result<Stylesheet, ParseError> {
19    let mut parser_input = ParserInput::new(input);
20    let mut parser = Parser::new(&mut parser_input);
21    let mut rule_parser = StylesheetRuleParser;
22    let mut rules = Vec::new();
23    let iter = StyleSheetParser::new(&mut parser, &mut rule_parser);
24    for item in iter {
25        match item {
26            Ok(Some(rule)) => rules.push(rule),
27            // `None` here is an at-rule we chose to swallow (e.g. `@media`,
28            // `@charset`) — keep parsing, don't surface as an error.
29            Ok(None) => {}
30            // CSS spec: a single malformed rule must not invalidate the
31            // surrounding stylesheet. cssparser already skips the broken
32            // rule's tokens before yielding the next item, so we drop the
33            // error and keep collecting.
34            Err(_) => {}
35        }
36    }
37    Ok(Stylesheet { rules })
38}
39
40struct StylesheetRuleParser;
41
42impl<'i> QualifiedRuleParser<'i> for StylesheetRuleParser {
43    type Prelude = Vec<Selector>;
44    type QualifiedRule = Option<Rule>;
45    type Error = ParseErrorOwned;
46
47    fn parse_prelude<'t>(
48        &mut self,
49        parser: &mut Parser<'i, 't>,
50    ) -> Result<Self::Prelude, CssParseError<'i, Self::Error>> {
51        parse_selector_list(parser)
52    }
53
54    fn parse_block<'t>(
55        &mut self,
56        selectors: Self::Prelude,
57        _start: &ParserState,
58        parser: &mut Parser<'i, 't>,
59    ) -> Result<Self::QualifiedRule, CssParseError<'i, Self::Error>> {
60        let mut declarations = Vec::new();
61        let mut decl_parser = DeclParser;
62        let body = RuleBodyParser::new(parser, &mut decl_parser);
63        // CSS spec: a malformed declaration must not invalidate the
64        // surrounding rule. cssparser already advances past the broken
65        // declaration; we just swallow the error and keep collecting the
66        // rest.
67        for item in body {
68            match item {
69                Ok(decl) => declarations.push(decl),
70                Err(_) => continue,
71            }
72        }
73        Ok(Some(Rule {
74            selectors,
75            declarations,
76        }))
77    }
78}
79
80impl<'i> AtRuleParser<'i> for StylesheetRuleParser {
81    type Prelude = ();
82    type AtRule = Option<Rule>;
83    type Error = ParseErrorOwned;
84
85    // Consume an at-rule prelude (everything up to `;` or `{`) and discard
86    // it. Returning Ok signals "recognized"; the matching parse_block
87    // (for block at-rules) or rule-list parser (for statement at-rules)
88    // will skip the body. v1 doesn't implement any at-rule semantics, but
89    // we swallow them so a real-world stylesheet with `@charset` /
90    // `@media` doesn't blow up the whole parse.
91    fn parse_prelude<'t>(
92        &mut self,
93        _name: CowRcStr<'i>,
94        parser: &mut Parser<'i, 't>,
95    ) -> Result<Self::Prelude, CssParseError<'i, Self::Error>> {
96        while parser.next().is_ok() {}
97        Ok(())
98    }
99
100    fn rule_without_block(
101        &mut self,
102        _prelude: Self::Prelude,
103        _start: &ParserState,
104    ) -> Result<Self::AtRule, ()> {
105        Ok(None)
106    }
107
108    fn parse_block<'t>(
109        &mut self,
110        _prelude: Self::Prelude,
111        _start: &ParserState,
112        parser: &mut Parser<'i, 't>,
113    ) -> Result<Self::AtRule, CssParseError<'i, Self::Error>> {
114        // Drain the block — its contents (nested rules or declarations)
115        // are intentionally discarded in v1.
116        while parser.next().is_ok() {}
117        Ok(None)
118    }
119}
120
121fn parse_selector_list<'i, 't>(
122    parser: &mut Parser<'i, 't>,
123) -> Result<Vec<Selector>, CssParseError<'i, ParseErrorOwned>> {
124    let mut selectors = Vec::new();
125    loop {
126        selectors.push(parse_compound_selector(parser)?);
127        if parser.expect_comma().is_err() {
128            break;
129        }
130    }
131    Ok(selectors)
132}
133
134/// Parse one [`SimpleSelector`] from the token stream. Consumes only
135/// tokens that belong to the simple selector (element, classes, pseudo).
136/// Stops — without consuming — at whitespace or any token that cannot
137/// continue the current simple selector.
138///
139/// `allow_type` controls whether a leading `Ident` token is accepted as a
140/// type selector. The very first part of a compound selector allows a type
141/// selector; subsequent parts (after an explicit combinator) also allow one
142/// since the combinator was already consumed. The only case that disallows
143/// a type selector is a non-first part reached via the whitespace/descendant
144/// path, where the Ident has already been put back by the caller.
145fn parse_simple_selector<'i, 't>(
146    parser: &mut Parser<'i, 't>,
147    allow_type: bool,
148) -> Result<SimpleSelector, CssParseError<'i, ParseErrorOwned>> {
149    let mut sel = SimpleSelector::default();
150    let mut saw_anything = false;
151    loop {
152        let save = parser.state();
153        let next = parser.next_including_whitespace().cloned();
154        match next {
155            Ok(Token::Ident(name)) if allow_type && !saw_anything => {
156                sel.element = Some(name.to_string());
157                saw_anything = true;
158            }
159            Ok(Token::Delim('.')) => {
160                let name = parser.expect_ident()?.to_string();
161                sel.classes.push(name);
162                saw_anything = true;
163            }
164            Ok(Token::Colon) => {
165                let ident = parser.expect_ident_cloned()?;
166                let pseudo = match PseudoClass::from_ident(&ident) {
167                    Some(p) => p,
168                    None => {
169                        return Err(parser.new_custom_error(ParseErrorOwned(format!(
170                            "unknown pseudo-class :{ident}"
171                        ))));
172                    }
173                };
174                if sel.pseudo.is_some() {
175                    return Err(parser.new_custom_error(ParseErrorOwned(
176                        "multiple pseudo-classes per selector are not supported".to_string(),
177                    )));
178                }
179                sel.pseudo = Some(pseudo);
180                saw_anything = true;
181            }
182            _ => {
183                parser.reset(&save);
184                break;
185            }
186        }
187    }
188    if !saw_anything {
189        return Err(parser.new_custom_error(ParseErrorOwned("empty selector".to_string())));
190    }
191    Ok(sel)
192}
193
194/// Parse a compound selector: one or more [`SimpleSelector`]s joined by
195/// [`Combinator`]s. Handles ` ` (descendant), `>` (child), `+`
196/// (adjacent sibling), `~` (general sibling).
197fn parse_compound_selector<'i, 't>(
198    parser: &mut Parser<'i, 't>,
199) -> Result<Selector, CssParseError<'i, ParseErrorOwned>> {
200    // Skip leading whitespace before the selector starts — cssparser keeps
201    // the whitespace token between e.g. `,` and the next selector.
202    parser.skip_whitespace();
203    let first = parse_simple_selector(parser, true)?;
204    let mut parts = vec![first];
205    let mut combinators = vec![];
206
207    loop {
208        // Peek at what follows: whitespace, explicit combinator token, or
209        // something that cannot continue a selector.
210        let save = parser.state();
211        let tok = parser.next_including_whitespace().cloned();
212
213        match tok {
214            // Explicit combinators: `>`, `+`, `~` (possibly with surrounding
215            // whitespace already consumed in the whitespace arm below).
216            Ok(Token::Delim('>')) => {
217                parser.skip_whitespace();
218                let next_simple = parse_simple_selector(parser, true)?;
219                parts.push(next_simple);
220                combinators.push(Combinator::Child);
221            }
222            Ok(Token::Delim('+')) => {
223                parser.skip_whitespace();
224                let next_simple = parse_simple_selector(parser, true)?;
225                parts.push(next_simple);
226                combinators.push(Combinator::AdjacentSibling);
227            }
228            Ok(Token::Delim('~')) => {
229                parser.skip_whitespace();
230                let next_simple = parse_simple_selector(parser, true)?;
231                parts.push(next_simple);
232                combinators.push(Combinator::GeneralSibling);
233            }
234            Ok(Token::WhiteSpace(_)) => {
235                // Could be a descendant combinator or just trailing
236                // whitespace before `{`. Peek further.
237                //
238                // Eat any *additional* whitespace tokens. cssparser emits
239                // one Token::WhiteSpace per contiguous whitespace run, but
240                // a CSS comment between two runs (e.g. `.a /* x */ .b`)
241                // separates them into two tokens. Without this skip the
242                // second whitespace falls into the `_` arm of the inner
243                // match, the loop breaks, and the trailing `.b` is left
244                // unconsumed — cssparser then drops the whole rule.
245                parser.skip_whitespace();
246                let after_ws = parser.state();
247                let next_tok = parser.next_including_whitespace().cloned();
248                match next_tok {
249                    // Explicit combinator after whitespace: ` > .b`, ` + .b`, ` ~ .b`.
250                    Ok(Token::Delim('>')) => {
251                        parser.skip_whitespace();
252                        let next_simple = parse_simple_selector(parser, true)?;
253                        parts.push(next_simple);
254                        combinators.push(Combinator::Child);
255                    }
256                    Ok(Token::Delim('+')) => {
257                        parser.skip_whitespace();
258                        let next_simple = parse_simple_selector(parser, true)?;
259                        parts.push(next_simple);
260                        combinators.push(Combinator::AdjacentSibling);
261                    }
262                    Ok(Token::Delim('~')) => {
263                        parser.skip_whitespace();
264                        let next_simple = parse_simple_selector(parser, true)?;
265                        parts.push(next_simple);
266                        combinators.push(Combinator::GeneralSibling);
267                    }
268                    // Tokens that can start a simple selector → descendant combinator.
269                    // Put the token back and re-parse — the Ident case needs
270                    // allow_type=true so `label span` works.
271                    Ok(Token::Ident(_)) | Ok(Token::Delim('.')) | Ok(Token::Colon) => {
272                        parser.reset(&after_ws);
273                        let next_simple = parse_simple_selector(parser, true)?;
274                        parts.push(next_simple);
275                        combinators.push(Combinator::Descendant);
276                    }
277                    _ => {
278                        // Trailing whitespace before `{` or end — not a
279                        // combinator. Back up past the whitespace too.
280                        parser.reset(&save);
281                        break;
282                    }
283                }
284            }
285            _ => {
286                parser.reset(&save);
287                break;
288            }
289        }
290    }
291
292    Ok(Selector { parts, combinators })
293}
294
295struct DeclParser;
296
297impl<'i> DeclarationParser<'i> for DeclParser {
298    type Declaration = Declaration;
299    type Error = ParseErrorOwned;
300
301    fn parse_value<'t>(
302        &mut self,
303        name: CowRcStr<'i>,
304        parser: &mut Parser<'i, 't>,
305        _start: &ParserState,
306    ) -> Result<Self::Declaration, CssParseError<'i, Self::Error>> {
307        let prop = name.to_string();
308        let (value, important) = parse_value(&prop, parser)?;
309        Ok(Declaration {
310            property: prop,
311            value,
312            important,
313        })
314    }
315}
316
317impl<'i> AtRuleParser<'i> for DeclParser {
318    type Prelude = ();
319    type AtRule = Declaration;
320    type Error = ParseErrorOwned;
321}
322
323impl<'i> QualifiedRuleParser<'i> for DeclParser {
324    type Prelude = ();
325    type QualifiedRule = Declaration;
326    type Error = ParseErrorOwned;
327}
328
329impl<'i> RuleBodyItemParser<'i, Declaration, ParseErrorOwned> for DeclParser {
330    fn parse_declarations(&self) -> bool {
331        true
332    }
333    fn parse_qualified(&self) -> bool {
334        false
335    }
336}
337
338fn parse_value<'i, 't>(
339    prop: &str,
340    parser: &mut Parser<'i, 't>,
341) -> Result<(Value, bool), CssParseError<'i, ParseErrorOwned>> {
342    let value = parse_value_inner(prop, parser)?;
343    let important = consume_important(parser);
344    parser.expect_exhausted()?;
345    Ok((value, important))
346}
347
348fn parse_value_inner<'i, 't>(
349    prop: &str,
350    parser: &mut Parser<'i, 't>,
351) -> Result<Value, CssParseError<'i, ParseErrorOwned>> {
352    // Per-property value type: each recognised property accepts only the
353    // value shape it actually means. This stops `color: nonsense` from
354    // silently parsing as a keyword and reaching the adapter.
355    match property_kind(prop) {
356        PropertyKind::Color => parse_color(parser).map(Value::Color),
357        PropertyKind::Length => parse_length(parser).map(Value::Length),
358        PropertyKind::LengthOrAuto => {
359            if parser.try_parse(expect_auto).is_ok() {
360                Ok(Value::Auto)
361            } else {
362                parse_length(parser).map(Value::Length)
363            }
364        }
365        PropertyKind::SideLengths => {
366            let mut lengths = Vec::new();
367            while let Ok(len) = parser.try_parse(parse_length) {
368                lengths.push(len);
369                if lengths.len() == 4 {
370                    break;
371                }
372            }
373            if lengths.is_empty() {
374                return Err(parser
375                    .new_custom_error(ParseErrorOwned(format!("expected length for `{prop}`"))));
376            }
377            Ok(Value::LengthSet(lengths))
378        }
379        PropertyKind::SideLengthsOrAuto => parse_side_lengths_or_auto(prop, parser),
380        PropertyKind::Keyword(allowed) => {
381            let ident = parser.try_parse(|p| p.expect_ident_cloned()).map_err(|_| {
382                parser.new_custom_error(ParseErrorOwned(format!("expected keyword for `{prop}`")))
383            })?;
384            let kw = ident.to_ascii_lowercase();
385            if allowed.contains(&kw.as_str()) {
386                Ok(Value::Keyword(kw))
387            } else {
388                Err(parser.new_custom_error(ParseErrorOwned(format!(
389                    "unknown keyword `{kw}` for `{prop}`"
390                ))))
391            }
392        }
393        PropertyKind::Number => {
394            let n = parser.try_parse(|p| p.expect_number()).map_err(|_| {
395                parser.new_custom_error(ParseErrorOwned(format!("expected number for `{prop}`")))
396            })?;
397            // `flex-grow` / `flex-shrink` are spec-required to be >= 0;
398            // every property using `PropertyKind::Number` today inherits
399            // that constraint. If a future property needs signed numbers,
400            // split into a separate `SignedNumber` kind.
401            if n < 0.0 {
402                return Err(parser.new_custom_error(ParseErrorOwned(format!(
403                    "negative number not allowed for `{prop}`"
404                ))));
405            }
406            Ok(Value::Number(f64::from(n)))
407        }
408        PropertyKind::NumberOrLength => {
409            // Try unitless number first (line-height: 1.5), then length.
410            // A dimension token like `24px` is NOT a plain Number in
411            // cssparser, so `expect_number` won't consume it — try in order.
412            if let Ok(n) = parser.try_parse(|p| {
413                let loc = p.current_source_location();
414                let tok = p.next()?.clone();
415                match tok {
416                    // Accept only a pure Number token (no unit).
417                    Token::Number { value, .. } => Ok(value),
418                    other => Err(loc.new_custom_error::<ParseErrorOwned, ParseErrorOwned>(
419                        ParseErrorOwned(format!("not a plain number: {other:?}")),
420                    )),
421                }
422            }) {
423                Ok(Value::Number(f64::from(n)))
424            } else {
425                parse_length(parser).map(Value::Length)
426            }
427        }
428        PropertyKind::FontWeight => {
429            if let Ok(n) = parser.try_parse(|p| p.expect_number()) {
430                let n = f64::from(n);
431                // CSS spec: font-weight numeric values must be integers in 1..=1000.
432                if n.fract() != 0.0 || !(1.0..=1000.0).contains(&n) {
433                    return Err(parser.new_custom_error(ParseErrorOwned(format!(
434                        "font-weight numeric value `{n}` is out of range (must be integer 1–1000)"
435                    ))));
436                }
437                Ok(Value::Number(n))
438            } else {
439                let ident = parser.try_parse(|p| p.expect_ident_cloned()).map_err(|_| {
440                    parser.new_custom_error(ParseErrorOwned(
441                        "expected number or keyword for `font-weight`".to_string(),
442                    ))
443                })?;
444                let kw = ident.to_ascii_lowercase();
445                if ["normal", "bold"].contains(&kw.as_str()) {
446                    Ok(Value::Keyword(kw))
447                } else {
448                    Err(parser.new_custom_error(ParseErrorOwned(format!(
449                        "unknown keyword `{kw}` for `font-weight`"
450                    ))))
451                }
452            }
453        }
454        PropertyKind::FontFamily => parse_font_family(parser),
455        PropertyKind::Border => parse_border_shorthand(prop, parser),
456        PropertyKind::Unknown => {
457            // Forward-compat: unknown properties (anything we'll grow into
458            // later) take whichever shape the value tokens fit. Try color,
459            // then length, then bare keyword.
460            if let Ok(c) = parser.try_parse(parse_color) {
461                return Ok(Value::Color(c));
462            }
463            if let Ok(len) = parser.try_parse(parse_length) {
464                return Ok(Value::Length(len));
465            }
466            if let Ok(ident) = parser.try_parse(|p| p.expect_ident_cloned()) {
467                // Match the lowercasing the strict keyword arm applies, so
468                // unknown-property keyword values cascade case-insensitively
469                // with each other.
470                return Ok(Value::Keyword(ident.to_ascii_lowercase()));
471            }
472            Err(parser.new_custom_error(ParseErrorOwned(format!(
473                "could not parse value for `{prop}`"
474            ))))
475        }
476    }
477}
478
479// -- side-lengths-or-auto ----------------------------------------------------
480
481fn parse_side_lengths_or_auto<'i, 't>(
482    prop: &str,
483    parser: &mut Parser<'i, 't>,
484) -> Result<Value, CssParseError<'i, ParseErrorOwned>> {
485    let mut sides: Vec<SideValue> = Vec::new();
486    loop {
487        if sides.len() == 4 {
488            break;
489        }
490        if let Ok(()) = parser.try_parse(expect_auto) {
491            sides.push(SideValue::Auto);
492        } else if let Ok(len) = parser.try_parse(parse_length) {
493            sides.push(SideValue::Length(len));
494        } else {
495            break;
496        }
497    }
498    if sides.is_empty() {
499        return Err(parser.new_custom_error(ParseErrorOwned(format!(
500            "expected length or auto for `{prop}`"
501        ))));
502    }
503    // Single `auto` token → Value::Auto (matches `width: auto` semantics).
504    if sides == [SideValue::Auto] {
505        return Ok(Value::Auto);
506    }
507    // If every side is a plain length, downcast to LengthSet so consumers
508    // that only handle LengthSet still work.
509    let all_lengths: Option<Vec<Length>> = sides
510        .iter()
511        .map(|sv| match sv {
512            SideValue::Length(l) => Some(*l),
513            SideValue::Auto => None,
514        })
515        .collect();
516    if let Some(lengths) = all_lengths {
517        return Ok(Value::LengthSet(lengths));
518    }
519    Ok(Value::SideSet(sides))
520}
521
522// -- font-family -------------------------------------------------------------
523
524fn parse_font_family<'i, 't>(
525    parser: &mut Parser<'i, 't>,
526) -> Result<Value, CssParseError<'i, ParseErrorOwned>> {
527    let mut families: Vec<String> = Vec::new();
528    let mut pending_comma = false;
529    loop {
530        // Accept a quoted string or one or more unquoted idents.
531        let pushed = if let Ok(s) = parser.try_parse(|p| p.expect_string_cloned()) {
532            families.push(s.to_string());
533            true
534        } else if let Ok(ident) = parser.try_parse(|p| p.expect_ident_cloned()) {
535            // Concatenate adjacent idents for multi-word names like `sans serif`.
536            // CSS spec: unquoted family name = sequence of idents.
537            let mut name = ident.to_string();
538            loop {
539                // peek: if next non-whitespace token is an ident (and no comma
540                // or EOF), keep appending.
541                let state = parser.state();
542                match parser.next_including_whitespace() {
543                    Ok(Token::WhiteSpace(_)) => {
544                        let state2 = parser.state();
545                        match parser.next_including_whitespace() {
546                            Ok(Token::Ident(next_ident)) => {
547                                name.push(' ');
548                                name.push_str(next_ident.as_ref());
549                            }
550                            _ => {
551                                parser.reset(&state2);
552                                break;
553                            }
554                        }
555                    }
556                    _ => {
557                        parser.reset(&state);
558                        break;
559                    }
560                }
561            }
562            families.push(name);
563            true
564        } else {
565            false
566        };
567        if !pushed {
568            if pending_comma {
569                // `font-family: "Hack",` — comma not followed by another
570                // family name. Reject so the malformed declaration is
571                // dropped.
572                return Err(parser
573                    .new_custom_error(ParseErrorOwned("trailing comma in font-family".into())));
574            }
575            break;
576        }
577        if parser.try_parse(|p| p.expect_comma()).is_err() {
578            break;
579        }
580        pending_comma = true;
581    }
582    if families.is_empty() {
583        return Err(parser.new_custom_error(ParseErrorOwned("expected font-family value".into())));
584    }
585    Ok(Value::FontFamilyList(families))
586}
587
588// -- border shorthand --------------------------------------------------------
589
590fn parse_border_shorthand<'i, 't>(
591    prop: &str,
592    parser: &mut Parser<'i, 't>,
593) -> Result<Value, CssParseError<'i, ParseErrorOwned>> {
594    // Accept `<length> [solid|none] <color>` in any order.
595    // `style` token if present must be `solid` or `none`; others reject.
596    // All three are required (width + color mandatory; style optional).
597    let mut width: Option<Length> = None;
598    let mut color: Option<Color> = None;
599    let mut saw_none_style = false;
600
601    // Try each token up to 3 times (at most: length, style, color).
602    for _ in 0..3 {
603        if width.is_none()
604            && let Ok(len) = parser.try_parse(parse_length)
605        {
606            width = Some(len);
607            continue;
608        }
609        if color.is_none()
610            && let Ok(c) = parser.try_parse(parse_color)
611        {
612            color = Some(c);
613            continue;
614        }
615        // style keyword: solid (accepted, ignored) or none (zero width).
616        if let Ok(ident) = parser.try_parse(|p| p.expect_ident_cloned()) {
617            let kw = ident.to_ascii_lowercase();
618            match kw.as_str() {
619                // `none` is special: it zeros the width when no width was
620                // given and makes the color optional.
621                "none" => {
622                    saw_none_style = true;
623                    continue;
624                }
625                // Every other style keyword (`solid`, `dashed`, `dotted`,
626                // `double`, `groove`, `ridge`, `inset`, `outset`, …) is
627                // accepted and ignored — floem has no border-style model,
628                // so we don't promote the choice into the AST. Erroring
629                // would drop the whole declaration including the width
630                // and color the user actually cares about.
631                _ => continue,
632            }
633        }
634        break;
635    }
636
637    let width = if saw_none_style {
638        // `none` style: width is optional. If the source gave an explicit
639        // width (e.g. `border: 1px none red`) keep it — the user is
640        // describing a transition-style hidden border. If they omitted the
641        // width (`border: none red` / `border: none`) treat the border as
642        // zero-thickness.
643        width.unwrap_or(Length::Px(0.0))
644    } else {
645        width.ok_or_else(|| {
646            parser.new_custom_error(ParseErrorOwned(format!("missing width in `{prop}`")))
647        })?
648    };
649    // `border: none` (no color) is the most common CSS reset. Allow it
650    // when the user wrote `none`: fall back to transparent so the border
651    // is structurally present in the AST but visually invisible.
652    let color = match color {
653        Some(c) => c,
654        None if saw_none_style => Color::rgba(0, 0, 0, 0),
655        None => {
656            return Err(
657                parser.new_custom_error(ParseErrorOwned(format!("missing color in `{prop}`")))
658            );
659        }
660    };
661
662    Ok(Value::Border { width, color })
663}
664
665// -- helpers -----------------------------------------------------------------
666
667fn expect_auto<'i, 't>(
668    parser: &mut Parser<'i, 't>,
669) -> Result<(), CssParseError<'i, ParseErrorOwned>> {
670    let loc = parser.current_source_location();
671    let tok = parser.next()?.clone();
672    match &tok {
673        Token::Ident(name) if name.eq_ignore_ascii_case("auto") => Ok(()),
674        other => {
675            Err(loc.new_custom_error(ParseErrorOwned(format!("expected `auto`, got {other:?}"))))
676        }
677    }
678}
679
680// -- property kind -----------------------------------------------------------
681
682#[derive(Debug, Clone)]
683enum PropertyKind {
684    Color,
685    Length,
686    /// `width`, `height`, `flex-basis`: length OR `auto`.
687    LengthOrAuto,
688    /// `padding`, `border-radius`: 1..=4 lengths only.
689    SideLengths,
690    /// `margin`: 1..=4 sides, each length or auto.
691    SideLengthsOrAuto,
692    /// Fixed set of keyword values.
693    Keyword(&'static [&'static str]),
694    /// Unitless number only.
695    Number,
696    /// Unitless number (yields `Value::Number`) OR length (yields `Value::Length`).
697    NumberOrLength,
698    FontFamily,
699    /// `font-weight`: integer in 1..=1000 OR keyword `normal`/`bold`.
700    FontWeight,
701    Border,
702    /// Forward-compat shape for properties not yet first-class.
703    Unknown,
704}
705
706fn property_kind(name: &str) -> PropertyKind {
707    match name {
708        "color" | "background-color" => PropertyKind::Color,
709
710        // Sizing
711        "width" | "height" | "flex-basis" => PropertyKind::LengthOrAuto,
712
713        // Box spacing
714        "padding" | "border-radius" => PropertyKind::SideLengths,
715        "margin" => PropertyKind::SideLengthsOrAuto,
716        "gap" | "row-gap" | "column-gap" => PropertyKind::Length,
717
718        // Layout
719        "display" => PropertyKind::Keyword(&["flex", "block", "none"]),
720        "flex-direction" => {
721            PropertyKind::Keyword(&["row", "column", "row-reverse", "column-reverse"])
722        }
723        "align-items" => PropertyKind::Keyword(&["start", "end", "center", "stretch", "baseline"]),
724        "justify-content" => PropertyKind::Keyword(&[
725            "start",
726            "end",
727            "center",
728            "space-between",
729            "space-around",
730            "space-evenly",
731        ]),
732        "flex-grow" | "flex-shrink" => PropertyKind::Number,
733
734        // Border shorthands
735        "border" | "border-top" | "border-right" | "border-bottom" | "border-left" | "outline" => {
736            PropertyKind::Border
737        }
738
739        // CSS spec: `border-width` is a 1..=4 length shorthand
740        // (top/right/bottom/left), same expansion rules as padding.
741        "border-width" => PropertyKind::SideLengths,
742        "border-color" => PropertyKind::Color,
743        "border-top-color" | "border-right-color" | "border-bottom-color" | "border-left-color" => {
744            PropertyKind::Color
745        }
746
747        // Typography
748        "font-family" => PropertyKind::FontFamily,
749        "font-size" => PropertyKind::Length,
750        "font-weight" => PropertyKind::FontWeight,
751        "font-style" => PropertyKind::Keyword(&["normal", "italic", "oblique"]),
752        "text-align" => PropertyKind::Keyword(&["left", "center", "right", "justify"]),
753        "line-height" => PropertyKind::NumberOrLength,
754
755        _ => PropertyKind::Unknown,
756    }
757}
758
759/// Consume a trailing `!important` if present. The flag is surfaced on
760/// the resulting [`Declaration`] and honoured by the cascade in
761/// [`crate::Stylesheet::resolve`] — important declarations beat any
762/// non-important declaration regardless of specificity, with source
763/// order breaking ties within either tier.
764fn consume_important(parser: &mut Parser<'_, '_>) -> bool {
765    parser
766        .try_parse(|p| -> Result<(), CssParseError<'_, ParseErrorOwned>> {
767            p.expect_delim('!')?;
768            let ident = p.expect_ident_cloned()?;
769            if ident.eq_ignore_ascii_case("important") {
770                Ok(())
771            } else {
772                Err(p.new_custom_error(ParseErrorOwned("not !important".to_string())))
773            }
774        })
775        .is_ok()
776}
777
778fn parse_length<'i, 't>(
779    parser: &mut Parser<'i, 't>,
780) -> Result<Length, CssParseError<'i, ParseErrorOwned>> {
781    let location = parser.current_source_location();
782    let token = parser.next()?.clone();
783    match token {
784        Token::Dimension { value, unit, .. } => match unit.as_ref() {
785            "px" => Ok(Length::Px(f64::from(value))),
786            other => Err(location.new_custom_error(ParseErrorOwned(format!(
787                // `em` / `rem` deferred to a later phase.
788                "unsupported length unit `{other}`"
789            )))),
790        },
791        Token::Percentage { unit_value, .. } => Ok(Length::Percent(f64::from(unit_value) * 100.0)),
792        Token::Number { value, .. } => Ok(Length::Px(f64::from(value))),
793        other => {
794            Err(location
795                .new_custom_error(ParseErrorOwned(format!("expected length, got {other:?}"))))
796        }
797    }
798}
799
800fn parse_color<'i, 't>(
801    parser: &mut Parser<'i, 't>,
802) -> Result<Color, CssParseError<'i, ParseErrorOwned>> {
803    let location = parser.current_source_location();
804    let token = parser.next()?.clone();
805    match token {
806        // cssparser emits `Hash` when the value starts with a digit
807        // (e.g. `#1a2b3c`) and `IDHash` otherwise — both are valid CSS
808        // colour syntax, so collapse them into a single arm.
809        Token::IDHash(h) | Token::Hash(h) => parse_hex(h.as_ref()).ok_or_else(|| {
810            location.new_custom_error(ParseErrorOwned(format!("bad hex color `#{h}`")))
811        }),
812        Token::Ident(name) => named_color(name.as_ref()).ok_or_else(|| {
813            location.new_custom_error(ParseErrorOwned(format!("unknown color name `{name}`")))
814        }),
815        Token::Function(name) => {
816            let name_lc = name.to_ascii_lowercase();
817            parser.parse_nested_block(|p| match name_lc.as_str() {
818                "rgb" => parse_rgb_args(p, false),
819                "rgba" => parse_rgb_args(p, true),
820                other => Err(p.new_custom_error(ParseErrorOwned(format!(
821                    "unsupported color function `{other}`"
822                )))),
823            })
824        }
825        other => {
826            Err(location
827                .new_custom_error(ParseErrorOwned(format!("expected color, got {other:?}"))))
828        }
829    }
830}
831
832fn parse_rgb_args<'i, 't>(
833    parser: &mut Parser<'i, 't>,
834    expect_alpha: bool,
835) -> Result<Color, CssParseError<'i, ParseErrorOwned>> {
836    let r = parse_u8_channel(parser)?;
837    parser.expect_comma()?;
838    let g = parse_u8_channel(parser)?;
839    parser.expect_comma()?;
840    let b = parse_u8_channel(parser)?;
841    let a = if expect_alpha {
842        parser.expect_comma()?;
843        let f = parser.expect_number()?;
844        (f.clamp(0.0, 1.0) * 255.0).round() as u8
845    } else {
846        0xff
847    };
848    parser.expect_exhausted()?;
849    Ok(Color::rgba(r, g, b, a))
850}
851
852fn parse_u8_channel<'i, 't>(
853    parser: &mut Parser<'i, 't>,
854) -> Result<u8, CssParseError<'i, ParseErrorOwned>> {
855    let location = parser.current_source_location();
856    let token = parser.next()?.clone();
857    let n = match token {
858        Token::Number { value, .. } => value,
859        Token::Percentage { unit_value, .. } => unit_value * 255.0,
860        other => {
861            return Err(location
862                .new_custom_error(ParseErrorOwned(format!("expected channel, got {other:?}"))));
863        }
864    };
865    Ok(n.clamp(0.0, 255.0).round() as u8)
866}
867
868fn parse_hex(s: &str) -> Option<Color> {
869    let hex = |c: char| c.to_digit(16).map(|d| d as u8);
870    let chars: Vec<u8> = s.chars().filter_map(hex).collect();
871    if chars.len() != s.chars().count() {
872        return None;
873    }
874    let dup = |n: u8| (n << 4) | n;
875    Some(match chars.len() {
876        3 => Color::rgb(dup(chars[0]), dup(chars[1]), dup(chars[2])),
877        4 => Color::rgba(dup(chars[0]), dup(chars[1]), dup(chars[2]), dup(chars[3])),
878        6 => Color::rgb(
879            (chars[0] << 4) | chars[1],
880            (chars[2] << 4) | chars[3],
881            (chars[4] << 4) | chars[5],
882        ),
883        8 => Color::rgba(
884            (chars[0] << 4) | chars[1],
885            (chars[2] << 4) | chars[3],
886            (chars[4] << 4) | chars[5],
887            (chars[6] << 4) | chars[7],
888        ),
889        _ => return None,
890    })
891}
892
893fn named_color(name: &str) -> Option<Color> {
894    // CSS Color Module 4 canonical hex values.
895    Some(match_ignore_ascii_case! { name,
896        "transparent" => Color::rgba(0, 0, 0, 0),
897        // CSS Level 1 (16 colors)
898        "black"   => Color::rgb(0x00, 0x00, 0x00),
899        "silver"  => Color::rgb(0xc0, 0xc0, 0xc0),
900        "gray"    => Color::rgb(0x80, 0x80, 0x80),
901        "grey"    => Color::rgb(0x80, 0x80, 0x80),
902        "white"   => Color::rgb(0xff, 0xff, 0xff),
903        "maroon"  => Color::rgb(0x80, 0x00, 0x00),
904        "red"     => Color::rgb(0xff, 0x00, 0x00),
905        "purple"  => Color::rgb(0x80, 0x00, 0x80),
906        "fuchsia" => Color::rgb(0xff, 0x00, 0xff),
907        "green"   => Color::rgb(0x00, 0x80, 0x00),
908        "lime"    => Color::rgb(0x00, 0xff, 0x00),
909        "olive"   => Color::rgb(0x80, 0x80, 0x00),
910        "yellow"  => Color::rgb(0xff, 0xff, 0x00),
911        "navy"    => Color::rgb(0x00, 0x00, 0x80),
912        "blue"    => Color::rgb(0x00, 0x00, 0xff),
913        "teal"    => Color::rgb(0x00, 0x80, 0x80),
914        "aqua"    => Color::rgb(0x00, 0xff, 0xff),
915        // Common aliases / extras
916        "cyan"    => Color::rgb(0x00, 0xff, 0xff),
917        "magenta" => Color::rgb(0xff, 0x00, 0xff),
918        "orange"  => Color::rgb(0xff, 0xa5, 0x00),
919        "brown"   => Color::rgb(0xa5, 0x2a, 0x2a),
920        "pink"    => Color::rgb(0xff, 0xc0, 0xcb),
921        _ => return None,
922    })
923}