Skip to main content

azul_css/
parser2.rs

1//! High-level types and functions related to CSS parsing.
2//!
3//! Main entry point: [`new_from_str`] parses a CSS string into a [`Css`] value
4//! plus a list of recoverable warnings. Errors are downgraded to warnings so
5//! that partially-valid CSS still produces usable output.
6//!
7//! Supports `@media`, `@theme`, `@os`, `@lang`, and `@container`
8//! at-rules, CSS nesting, CSS variables (`var(--name, default)`), and
9//! comma-separated selector lists. Tokenisation is delegated to `azul_simplecss`.
10//!
11//! Most error types come in borrowed/owned pairs (e.g. `CssParseError<'a>` /
12//! `CssParseErrorOwned`) so they can be returned across the FFI boundary.
13use alloc::{collections::BTreeMap, string::ToString, vec::Vec};
14use core::{fmt, num::ParseIntError};
15
16pub use azul_simplecss::Error as SimplecssError;
17use azul_simplecss::Tokenizer;
18
19/// FFI-safe position of a CSS syntax error.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[repr(C)]
22pub struct CssSyntaxErrorPos {
23    pub row: usize,
24    pub col: usize,
25}
26
27impl From<azul_simplecss::ErrorPos> for CssSyntaxErrorPos {
28    fn from(p: azul_simplecss::ErrorPos) -> Self {
29        Self { row: p.row, col: p.col }
30    }
31}
32
33/// FFI-safe wrapper for invalid advance details in CSS syntax errors.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(C)]
36pub struct CssSyntaxInvalidAdvance {
37    pub expected: isize,
38    pub total: usize,
39    pub pos: CssSyntaxErrorPos,
40}
41
42/// FFI-safe CSS syntax error type, mirrors `azul_simplecss::Error`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[repr(C, u8)]
45pub enum CssSyntaxError {
46    UnexpectedEndOfStream(CssSyntaxErrorPos),
47    InvalidAdvance(CssSyntaxInvalidAdvance),
48    UnsupportedToken(CssSyntaxErrorPos),
49    UnknownToken(CssSyntaxErrorPos),
50}
51
52impl From<SimplecssError> for CssSyntaxError {
53    fn from(e: SimplecssError) -> Self {
54        match e {
55            SimplecssError::UnexpectedEndOfStream(pos) => Self::UnexpectedEndOfStream(pos.into()),
56            SimplecssError::InvalidAdvance { expected, total, pos } => Self::InvalidAdvance(CssSyntaxInvalidAdvance { expected, total, pos: pos.into() }),
57            SimplecssError::UnsupportedToken(pos) => Self::UnsupportedToken(pos.into()),
58            SimplecssError::UnknownToken(pos) => Self::UnknownToken(pos.into()),
59        }
60    }
61}
62
63pub use crate::props::property::CssParsingError;
64use crate::{
65    corety::{AzString, OptionString},
66    css::{
67        AttributeMatchOp, Css, CssAttributeSelector, CssDeclaration, CssNthChildSelector, CssPath,
68        CssPathPseudoSelector, CssPathSelector, CssRuleBlock, DynamicCssProperty, NodeTypeTag,
69        NodeTypeTagParseError, NodeTypeTagParseErrorOwned,
70    },
71    dynamic_selector::{
72        BoolCondition, DynamicSelector, DynamicSelectorVec, LanguageCondition, MediaType,
73        MinMaxRange, OrientationType, OsCondition, ThemeCondition, parse_os_version,
74    },
75    props::{
76        basic::parse::parse_parentheses,
77        property::{
78            parse_combined_css_property, parse_css_property, CombinedCssPropertyType, CssKeyMap,
79            CssParsingErrorOwned, CssPropertyType,
80        },
81    },
82};
83
84/// Error that can happen during the parsing of a CSS value
85#[derive(Debug, Clone, PartialEq)]
86pub struct CssParseError<'a> {
87    pub css_string: &'a str,
88    pub error: CssParseErrorInner<'a>,
89    pub location: ErrorLocationRange,
90}
91
92/// Owned version of `CssParseError`, without references.
93#[derive(Debug, Clone, PartialEq)]
94#[repr(C)]
95pub struct CssParseErrorOwned {
96    pub css_string: AzString,
97    pub error: CssParseErrorInnerOwned,
98    pub location: ErrorLocationRange,
99}
100
101impl CssParseError<'_> {
102    #[must_use] pub fn to_contained(&self) -> CssParseErrorOwned {
103        CssParseErrorOwned {
104            css_string: self.css_string.to_string().into(),
105            error: self.error.to_contained(),
106            location: self.location,
107        }
108    }
109}
110
111impl CssParseErrorOwned {
112    #[must_use] pub fn to_shared(&self) -> CssParseError<'_> {
113        CssParseError {
114            css_string: self.css_string.as_str(),
115            error: self.error.to_shared(),
116            location: self.location,
117        }
118    }
119}
120
121/// Clamps a byte offset into `s` so it is in-bounds AND on a UTF-8 char boundary,
122/// rounding DOWN to the nearest boundary.
123///
124/// Error locations are recorded as raw byte offsets and are reachable from public,
125/// unvalidated fields, so they cannot be fed to a slice directly: an out-of-range or
126/// mid-character offset panics. Every error-reporting path routes through here.
127#[must_use]
128fn clamp_to_char_boundary(s: &str, pos: usize) -> usize {
129    let mut pos = pos.min(s.len());
130    while pos > 0 && !s.is_char_boundary(pos) {
131        pos -= 1;
132    }
133    pos
134}
135
136impl<'a> CssParseError<'a> {
137    /// Returns the string between the (start, end) location
138    #[must_use] pub fn get_error_string(&self) -> &'a str {
139        let (start, end) = (self.location.start.original_pos, self.location.end.original_pos);
140        // `location` is a pub field on a pub struct and `CssParseErrorOwned::to_shared`
141        // rebuilds one without revalidating, so start/end are NOT trustworthy: they can
142        // sit past the end, be reversed, or land inside a multi-byte char. A raw slice
143        // panics on all three -- while merely *displaying* an error. Clamp instead.
144        let start = clamp_to_char_boundary(self.css_string, start);
145        let end = clamp_to_char_boundary(self.css_string, end);
146        let (start, end) = (start.min(end), start.max(end));
147        self.css_string[start..end].trim()
148    }
149}
150
151#[derive(Debug, Clone, PartialEq)]
152pub enum CssParseErrorInner<'a> {
153    /// A hard error in the CSS syntax
154    ParseError(CssSyntaxError),
155    /// Braces are not balanced properly
156    UnclosedBlock,
157    /// Invalid syntax, such as `#div { #div: "my-value" }`
158    MalformedCss,
159    /// Error parsing dynamic CSS property, such as
160    /// `#div { width: {{ my_id }} /* no default case */ }`
161    DynamicCssParseError(DynamicCssParseError<'a>),
162    /// Error while parsing a pseudo selector (like `:aldkfja`)
163    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
164    /// The path has to be either `*`, `div`, `p` or something like that
165    NodeTypeTag(NodeTypeTagParseError<'a>),
166    /// A certain property has an unknown key, for example: `alsdfkj: 500px` = `unknown CSS key
167    /// "alsdfkj: 500px"`
168    UnknownPropertyKey(&'a str, &'a str),
169    /// `var()` can't be used on properties that expand to multiple values, since they would be
170    /// ambiguous and degrade performance - for example `margin: var(--blah)` would be ambiguous
171    /// because it's not clear when setting the variable, whether all sides should be set,
172    /// instead, you have to use `margin-top: var(--blah)`, `margin-bottom: var(--baz)` in order
173    /// to work around this limitation.
174    VarOnShorthandProperty {
175        key: CombinedCssPropertyType,
176        value: &'a str,
177    },
178}
179
180/// Wrapper for `UnknownPropertyKey` error.
181#[derive(Debug, Clone, PartialEq, Eq)]
182#[repr(C)]
183pub struct UnknownPropertyKeyError {
184    pub key: AzString,
185    pub value: AzString,
186}
187
188/// Wrapper for `VarOnShorthandProperty` error.
189#[derive(Debug, Clone, PartialEq, Eq)]
190#[repr(C)]
191pub struct VarOnShorthandPropertyError {
192    pub key: CombinedCssPropertyType,
193    pub value: AzString,
194}
195
196#[derive(Debug, Clone, PartialEq)]
197#[repr(C, u8)]
198pub enum CssParseErrorInnerOwned {
199    ParseError(CssSyntaxError),
200    UnclosedBlock,
201    MalformedCss,
202    DynamicCssParseError(DynamicCssParseErrorOwned),
203    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
204    NodeTypeTag(NodeTypeTagParseErrorOwned),
205    UnknownPropertyKey(UnknownPropertyKeyError),
206    VarOnShorthandProperty(VarOnShorthandPropertyError),
207}
208
209impl CssParseErrorInner<'_> {
210    #[must_use] pub fn to_contained(&self) -> CssParseErrorInnerOwned {
211        match self {
212            CssParseErrorInner::ParseError(e) => CssParseErrorInnerOwned::ParseError(*e),
213            CssParseErrorInner::UnclosedBlock => CssParseErrorInnerOwned::UnclosedBlock,
214            CssParseErrorInner::MalformedCss => CssParseErrorInnerOwned::MalformedCss,
215            CssParseErrorInner::DynamicCssParseError(e) => {
216                CssParseErrorInnerOwned::DynamicCssParseError(e.to_contained())
217            }
218            CssParseErrorInner::PseudoSelectorParseError(e) => {
219                CssParseErrorInnerOwned::PseudoSelectorParseError(e.to_contained())
220            }
221            CssParseErrorInner::NodeTypeTag(e) => {
222                CssParseErrorInnerOwned::NodeTypeTag(e.to_contained())
223            }
224            CssParseErrorInner::UnknownPropertyKey(a, b) => {
225                CssParseErrorInnerOwned::UnknownPropertyKey(UnknownPropertyKeyError { key: (*a).to_string().into(), value: (*b).to_string().into() })
226            }
227            CssParseErrorInner::VarOnShorthandProperty { key, value } => {
228                CssParseErrorInnerOwned::VarOnShorthandProperty(VarOnShorthandPropertyError {
229                    key: *key,
230                    value: (*value).to_string().into(),
231                })
232            }
233        }
234    }
235}
236
237impl CssParseErrorInnerOwned {
238    #[must_use] pub fn to_shared(&self) -> CssParseErrorInner<'_> {
239        match self {
240            Self::ParseError(e) => CssParseErrorInner::ParseError(*e),
241            Self::UnclosedBlock => CssParseErrorInner::UnclosedBlock,
242            Self::MalformedCss => CssParseErrorInner::MalformedCss,
243            Self::DynamicCssParseError(e) => {
244                CssParseErrorInner::DynamicCssParseError(e.to_shared())
245            }
246            Self::PseudoSelectorParseError(e) => {
247                CssParseErrorInner::PseudoSelectorParseError(e.to_shared())
248            }
249            Self::NodeTypeTag(e) => {
250                CssParseErrorInner::NodeTypeTag(e.to_shared())
251            }
252            Self::UnknownPropertyKey(e) => {
253                CssParseErrorInner::UnknownPropertyKey(e.key.as_str(), e.value.as_str())
254            }
255            Self::VarOnShorthandProperty(e) => {
256                CssParseErrorInner::VarOnShorthandProperty {
257                    key: e.key,
258                    value: e.value.as_str(),
259                }
260            }
261        }
262    }
263}
264
265impl_display! { CssParseErrorInner<'a>, {
266    ParseError(e) => format!("Parse Error: {:?}", e),
267    UnclosedBlock => "Unclosed block",
268    MalformedCss => "Malformed Css",
269    DynamicCssParseError(e) => format!("{}", e),
270    PseudoSelectorParseError(e) => format!("Failed to parse pseudo-selector: {}", e),
271    NodeTypeTag(e) => format!("Failed to parse CSS selector path: {}", e),
272    UnknownPropertyKey(k, v) => format!("Unknown CSS key: \"{}: {}\"", k, v),
273    VarOnShorthandProperty { key, value } => format!(
274        "Error while parsing: \"{}: {};\": var() cannot be used on shorthand properties - use `{}-top` or `{}-x` as the key instead: ",
275        key, value, key, key
276    ),
277}}
278
279impl From<CssSyntaxError> for CssParseErrorInner<'_> {
280    fn from(e: CssSyntaxError) -> Self {
281        CssParseErrorInner::ParseError(e)
282    }
283}
284
285impl From<SimplecssError> for CssParseErrorInner<'_> {
286    fn from(e: SimplecssError) -> Self {
287        CssParseErrorInner::ParseError(CssSyntaxError::from(e))
288    }
289}
290
291impl_from! { DynamicCssParseError<'a>, CssParseErrorInner::DynamicCssParseError }
292impl_from! { NodeTypeTagParseError<'a>, CssParseErrorInner::NodeTypeTag }
293impl_from! { CssPseudoSelectorParseError<'a>, CssParseErrorInner::PseudoSelectorParseError }
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum CssPseudoSelectorParseError<'a> {
297    EmptyNthChild,
298    UnknownSelector(&'a str, Option<&'a str>),
299    InvalidNthChildPattern(&'a str),
300    InvalidNthChild(ParseIntError),
301}
302
303impl From<ParseIntError> for CssPseudoSelectorParseError<'_> {
304    fn from(e: ParseIntError) -> Self {
305        CssPseudoSelectorParseError::InvalidNthChild(e)
306    }
307}
308
309impl_display! { CssPseudoSelectorParseError<'a>, {
310    EmptyNthChild => format!("\
311        Empty :nth-child() selector - nth-child() must at least take a number, \
312        a pattern (such as \"2n+3\") or the values \"even\" or \"odd\"."
313    ),
314    UnknownSelector(selector, value) => {
315        let format_str = value
316            .as_ref()
317            .map_or_else(|| (*selector).to_string(), |v| format!("{selector}({v})"));
318        format!("Invalid or unknown CSS pseudo-selector: ':{format_str}'")
319    },
320    InvalidNthChildPattern(selector) => format!(
321        "Invalid pseudo-selector :{} - value has to be a \
322        number, \"even\" or \"odd\" or a pattern such as \"2n+3\"", selector
323    ),
324    InvalidNthChild(e) => format!("Invalid :nth-child pseudo-selector: ':{}'", e),
325}}
326
327/// Wrapper for `UnknownSelector` error.
328#[derive(Debug, Clone, PartialEq, Eq)]
329#[repr(C)]
330pub struct UnknownSelectorError {
331    pub selector: AzString,
332    pub suggestion: OptionString,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336#[repr(C, u8)]
337pub enum CssPseudoSelectorParseErrorOwned {
338    EmptyNthChild,
339    UnknownSelector(UnknownSelectorError),
340    InvalidNthChildPattern(AzString),
341    InvalidNthChild(crate::props::basic::error::ParseIntError),
342}
343
344impl CssPseudoSelectorParseError<'_> {
345    #[must_use] pub fn to_contained(&self) -> CssPseudoSelectorParseErrorOwned {
346        match self {
347            CssPseudoSelectorParseError::EmptyNthChild => {
348                CssPseudoSelectorParseErrorOwned::EmptyNthChild
349            }
350            CssPseudoSelectorParseError::UnknownSelector(a, b) => {
351                CssPseudoSelectorParseErrorOwned::UnknownSelector(UnknownSelectorError {
352                    selector: (*a).to_string().into(),
353                    suggestion: b.map(|s| AzString::from(s.to_string())).into(),
354                })
355            }
356            CssPseudoSelectorParseError::InvalidNthChildPattern(s) => {
357                CssPseudoSelectorParseErrorOwned::InvalidNthChildPattern((*s).to_string().into())
358            }
359            CssPseudoSelectorParseError::InvalidNthChild(e) => {
360                CssPseudoSelectorParseErrorOwned::InvalidNthChild(e.clone().into())
361            }
362        }
363    }
364}
365
366impl CssPseudoSelectorParseErrorOwned {
367    #[must_use] pub fn to_shared(&self) -> CssPseudoSelectorParseError<'_> {
368        match self {
369            Self::EmptyNthChild => {
370                CssPseudoSelectorParseError::EmptyNthChild
371            }
372            Self::UnknownSelector(e) => {
373                CssPseudoSelectorParseError::UnknownSelector(e.selector.as_str(), e.suggestion.as_ref().map(AzString::as_str))
374            }
375            Self::InvalidNthChildPattern(s) => {
376                CssPseudoSelectorParseError::InvalidNthChildPattern(s)
377            }
378            Self::InvalidNthChild(e) => {
379                CssPseudoSelectorParseError::InvalidNthChild(e.to_std())
380            }
381        }
382    }
383}
384
385/// Error that can happen during `css_parser::parse_key_value_pair`
386#[derive(Debug, Clone, PartialEq)]
387pub enum DynamicCssParseError<'a> {
388    /// The brace contents aren't valid, i.e. `var(asdlfkjasf)`
389    InvalidBraceContents(&'a str),
390    /// Unexpected value when parsing the string
391    UnexpectedValue(CssParsingError<'a>),
392}
393
394impl_display! { DynamicCssParseError<'a>, {
395    InvalidBraceContents(e) => format!("Invalid contents of var() function: var({})", e),
396    UnexpectedValue(e) => format!("{}", e),
397}}
398
399impl<'a> From<CssParsingError<'a>> for DynamicCssParseError<'a> {
400    fn from(e: CssParsingError<'a>) -> Self {
401        DynamicCssParseError::UnexpectedValue(e)
402    }
403}
404
405#[derive(Debug, Clone, PartialEq)]
406#[repr(C, u8)]
407pub enum DynamicCssParseErrorOwned {
408    InvalidBraceContents(AzString),
409    UnexpectedValue(CssParsingErrorOwned),
410}
411
412impl DynamicCssParseError<'_> {
413    #[must_use] pub fn to_contained(&self) -> DynamicCssParseErrorOwned {
414        match self {
415            DynamicCssParseError::InvalidBraceContents(s) => {
416                DynamicCssParseErrorOwned::InvalidBraceContents((*s).to_string().into())
417            }
418            DynamicCssParseError::UnexpectedValue(e) => {
419                DynamicCssParseErrorOwned::UnexpectedValue(e.to_contained())
420            }
421        }
422    }
423}
424
425impl DynamicCssParseErrorOwned {
426    #[must_use] pub fn to_shared(&self) -> DynamicCssParseError<'_> {
427        match self {
428            Self::InvalidBraceContents(s) => {
429                DynamicCssParseError::InvalidBraceContents(s)
430            }
431            Self::UnexpectedValue(e) => {
432                DynamicCssParseError::UnexpectedValue(e.to_shared())
433            }
434        }
435    }
436}
437
438/// "selector" contains the actual selector such as "nth-child" while "value" contains
439/// an optional value - for example "nth-child(3)" would be: selector: "nth-child", value: "3".
440/// # Errors
441///
442/// Returns an error if `selector` (with optional `value`) is not a recognized CSS pseudo-selector.
443pub fn pseudo_selector_from_str<'a>(
444    selector: &'a str,
445    value: Option<&'a str>,
446) -> Result<CssPathPseudoSelector, CssPseudoSelectorParseError<'a>> {
447    match selector {
448        "first" => Ok(CssPathPseudoSelector::First),
449        "last" => Ok(CssPathPseudoSelector::Last),
450        "hover" => Ok(CssPathPseudoSelector::Hover),
451        "active" => Ok(CssPathPseudoSelector::Active),
452        "focus" => Ok(CssPathPseudoSelector::Focus),
453        "dragging" => Ok(CssPathPseudoSelector::Dragging),
454        "drag-over" => Ok(CssPathPseudoSelector::DragOver),
455        "root" => Ok(CssPathPseudoSelector::Root),
456        "nth-child" => {
457            let value = value.ok_or(CssPseudoSelectorParseError::EmptyNthChild)?;
458            let parsed = parse_nth_child_selector(value)?;
459            Ok(CssPathPseudoSelector::NthChild(parsed))
460        }
461        "lang" => {
462            let lang_value = value.ok_or(CssPseudoSelectorParseError::UnknownSelector(
463                selector, value,
464            ))?;
465            // Remove quotes if present
466            let lang_value = lang_value
467                .trim()
468                .trim_start_matches('"')
469                .trim_end_matches('"')
470                .trim_start_matches('\'')
471                .trim_end_matches('\'')
472                .trim();
473            Ok(CssPathPseudoSelector::Lang(AzString::from(
474                lang_value.to_string(),
475            )))
476        }
477        _ => Err(CssPseudoSelectorParseError::UnknownSelector(
478            selector, value,
479        )),
480    }
481}
482
483/// Parses the inner content of an attribute selector token (the text between `[` and `]`).
484///
485/// Returns `None` if the input is malformed (empty name, unterminated quote, etc).
486#[must_use] pub fn parse_attribute_selector(input: &str) -> Option<CssAttributeSelector> {
487    let s = input.trim();
488    if s.is_empty() {
489        return None;
490    }
491
492    // Find the operator (the longest match wins): try the compound operators
493    // first (in order), then the bare `=`, otherwise it is an existence check.
494    let compound_ops: [(&str, AttributeMatchOp); 5] = [
495        ("~=", AttributeMatchOp::Includes),
496        ("|=", AttributeMatchOp::DashMatch),
497        ("^=", AttributeMatchOp::Prefix),
498        ("$=", AttributeMatchOp::Suffix),
499        ("*=", AttributeMatchOp::Substring),
500    ];
501    let (op, op_pos): (AttributeMatchOp, Option<usize>) = compound_ops
502        .iter()
503        .find_map(|(pat, op)| s.find(pat).map(|i| (*op, Some(i))))
504        .or_else(|| s.find('=').map(|i| (AttributeMatchOp::Eq, Some(i))))
505        .unwrap_or((AttributeMatchOp::Exists, None));
506
507    let (name, value) = match op_pos {
508        None => (s, None),
509        Some(i) => {
510            let name = s[..i].trim();
511            let op_len = if matches!(op, AttributeMatchOp::Eq) { 1 } else { 2 };
512            let raw_value = s[i + op_len..].trim();
513            let unquoted = strip_attribute_quotes(raw_value)?;
514            (name, Some(unquoted))
515        }
516    };
517
518    if name.is_empty() {
519        return None;
520    }
521    // Reject names that contain whitespace or quotes.
522    if name.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') {
523        return None;
524    }
525
526    Some(CssAttributeSelector {
527        name: name.to_string().into(),
528        op,
529        value: value
530            .map_or_else(|| OptionString::None, |v| OptionString::Some(v.to_string().into())),
531    })
532}
533
534/// Strips matching surrounding `"` or `'` from a value. If the value is unquoted,
535/// returns it unchanged. Returns `None` if quoting is unbalanced.
536fn strip_attribute_quotes(s: &str) -> Option<&str> {
537    let bytes = s.as_bytes();
538    if bytes.len() >= 2 {
539        let first = bytes[0];
540        let last = bytes[bytes.len() - 1];
541        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
542            return Some(&s[1..s.len() - 1]);
543        }
544        if first == b'"' || first == b'\'' || last == b'"' || last == b'\'' {
545            // Unbalanced quote.
546            return None;
547        }
548    } else if bytes.len() == 1 && (bytes[0] == b'"' || bytes[0] == b'\'') {
549        return None;
550    }
551    Some(s)
552}
553
554/// Parses the inner value of the `:nth-child` selector, including numbers and patterns.
555///
556/// I.e.: `"2n+3"` -> `Pattern { repeat: 2, offset: 3 }`
557fn parse_nth_child_selector(
558    value: &str,
559) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'_>> {
560    let value = value.trim();
561
562    if value.is_empty() {
563        return Err(CssPseudoSelectorParseError::EmptyNthChild);
564    }
565
566    if let Ok(number) = value.parse::<u32>() {
567        return Ok(CssNthChildSelector::Number(number));
568    }
569
570    // If the value is not a number
571    match value {
572        "even" => Ok(CssNthChildSelector::Even),
573        "odd" => Ok(CssNthChildSelector::Odd),
574        _ => parse_nth_child_pattern(value),
575    }
576}
577
578/// Parses the pattern between the braces of a "nth-child" (such as "2n+3").
579fn parse_nth_child_pattern(
580    value: &str,
581) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'_>> {
582    use crate::css::CssNthChildPattern;
583
584    let value = value.trim();
585
586    if value.is_empty() {
587        return Err(CssPseudoSelectorParseError::EmptyNthChild);
588    }
589
590    // TODO: Test for "+"
591    let repeat = value
592        .split('n')
593        .next()
594        .ok_or(CssPseudoSelectorParseError::InvalidNthChildPattern(value))?
595        .trim()
596        .parse::<u32>()?;
597
598    // In a "2n+3" form, the first .next() yields the "2n", the second .next() yields the "3"
599    let mut offset_iterator = value.split('+');
600
601    // has to succeed, since the string is verified to not be empty
602    offset_iterator.next().unwrap();
603
604    let offset = match offset_iterator.next() {
605        Some(offset_string) => {
606            let offset_string = offset_string.trim();
607            if offset_string.is_empty() {
608                return Err(CssPseudoSelectorParseError::InvalidNthChildPattern(value));
609            }
610            offset_string.parse::<u32>()?
611        }
612        None => 0,
613    };
614
615    Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
616        pattern_repeat: repeat,
617        offset,
618    }))
619}
620
621#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
622#[repr(C)]
623pub struct ErrorLocation {
624    pub original_pos: usize,
625}
626
627/// FFI-safe replacement for `(ErrorLocation, ErrorLocation)` tuple.
628/// Represents a range (start..end) in the source text.
629#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
630#[repr(C)]
631pub struct ErrorLocationRange {
632    pub start: ErrorLocation,
633    pub end: ErrorLocation,
634}
635
636impl ErrorLocation {
637    /// Given an error location, returns the (line, column)
638    #[must_use] pub fn get_line_column_from_error(&self, css_string: &str) -> (usize, usize) {
639        // `original_pos` is a pub field and, at Token::EndOfStream, `get_error_location`
640        // records it as exactly `css_string.len()` -- so `- 1` lands INSIDE the final
641        // character whenever the stylesheet ends in a multi-byte char, and an
642        // out-of-range value is trivially constructible. Both used to panic here, i.e.
643        // simply Display-ing a parse error on Unicode CSS would abort.
644        let error_location =
645            clamp_to_char_boundary(css_string, self.original_pos.saturating_sub(1));
646        let (mut line_number, mut total_characters) = (0, 0);
647
648        for line in css_string[0..error_location].lines() {
649            line_number += 1;
650            total_characters += line.chars().count();
651        }
652
653        // Rust doesn't count "\n" as a character, so we have to add the line number count on top
654        let total_characters = total_characters + line_number;
655        let column_pos = error_location - total_characters.saturating_sub(2);
656
657        (line_number, column_pos)
658    }
659}
660
661impl fmt::Display for CssParseError<'_> {
662    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        let start_location = self.location.start.get_line_column_from_error(self.css_string);
664        let end_location = self.location.end.get_line_column_from_error(self.css_string);
665        write!(
666            f,
667            "    start: line {}:{}\r\n    end: line {}:{}\r\n    text: \"{}\"\r\n    reason: {}",
668            start_location.0,
669            start_location.1,
670            end_location.0,
671            end_location.1,
672            self.get_error_string(),
673            self.error,
674        )
675    }
676}
677
678/// Parses a CSS string into a [`Css`] value and a list of recoverable warnings.
679///
680/// Never panics. Syntax errors and unsupported properties are collected as
681/// [`CssParseWarnMsg`] items rather than causing a hard failure, so the caller
682/// always receives a (possibly empty) stylesheet.
683#[must_use] pub fn new_from_str(css_string: &str) -> (Css, Vec<CssParseWarnMsg<'_>>) {
684    let mut tokenizer = Tokenizer::new(css_string);
685    let (rules, warnings) = new_from_str_inner(css_string, &mut tokenizer);
686
687    (
688        Css { rules: rules.into() },
689        warnings,
690    )
691}
692
693/// Returns the location of where the parser is currently in the document
694fn get_error_location(tokenizer: &Tokenizer<'_>) -> ErrorLocation {
695    ErrorLocation {
696        original_pos: tokenizer.pos(),
697    }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
701pub enum CssPathParseError<'a> {
702    EmptyPath,
703    /// Invalid item encountered in string (for example a "{", "}")
704    InvalidTokenEncountered(&'a str),
705    UnexpectedEndOfStream(&'a str),
706    SyntaxError(CssSyntaxError),
707    /// The path has to be either `*`, `div`, `p` or something like that
708    NodeTypeTag(NodeTypeTagParseError<'a>),
709    /// Error while parsing a pseudo selector (like `:aldkfja`)
710    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
711}
712
713impl_from! { NodeTypeTagParseError<'a>, CssPathParseError::NodeTypeTag }
714impl_from! { CssPseudoSelectorParseError<'a>, CssPathParseError::PseudoSelectorParseError }
715
716impl From<CssSyntaxError> for CssPathParseError<'_> {
717    fn from(e: CssSyntaxError) -> Self {
718        CssPathParseError::SyntaxError(e)
719    }
720}
721
722impl From<SimplecssError> for CssPathParseError<'_> {
723    fn from(e: SimplecssError) -> Self {
724        CssPathParseError::SyntaxError(CssSyntaxError::from(e))
725    }
726}
727
728#[derive(Debug, Clone, PartialEq, Eq)]
729pub enum CssPathParseErrorOwned {
730    EmptyPath,
731    InvalidTokenEncountered(AzString),
732    UnexpectedEndOfStream(AzString),
733    SyntaxError(CssSyntaxError),
734    NodeTypeTag(NodeTypeTagParseErrorOwned),
735    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
736}
737
738impl CssPathParseError<'_> {
739    #[must_use] pub fn to_contained(&self) -> CssPathParseErrorOwned {
740        match self {
741            CssPathParseError::EmptyPath => CssPathParseErrorOwned::EmptyPath,
742            CssPathParseError::InvalidTokenEncountered(s) => {
743                CssPathParseErrorOwned::InvalidTokenEncountered((*s).to_string().into())
744            }
745            CssPathParseError::UnexpectedEndOfStream(s) => {
746                CssPathParseErrorOwned::UnexpectedEndOfStream((*s).to_string().into())
747            }
748            CssPathParseError::SyntaxError(e) => CssPathParseErrorOwned::SyntaxError(*e),
749            CssPathParseError::NodeTypeTag(e) => {
750                CssPathParseErrorOwned::NodeTypeTag(e.to_contained())
751            }
752            CssPathParseError::PseudoSelectorParseError(e) => {
753                CssPathParseErrorOwned::PseudoSelectorParseError(e.to_contained())
754            }
755        }
756    }
757}
758
759impl CssPathParseErrorOwned {
760    #[must_use] pub fn to_shared(&self) -> CssPathParseError<'_> {
761        match self {
762            Self::EmptyPath => CssPathParseError::EmptyPath,
763            Self::InvalidTokenEncountered(s) => {
764                CssPathParseError::InvalidTokenEncountered(s)
765            }
766            Self::UnexpectedEndOfStream(s) => {
767                CssPathParseError::UnexpectedEndOfStream(s)
768            }
769            Self::SyntaxError(e) => CssPathParseError::SyntaxError(*e),
770            Self::NodeTypeTag(e) => CssPathParseError::NodeTypeTag(e.to_shared()),
771            Self::PseudoSelectorParseError(e) => {
772                CssPathParseError::PseudoSelectorParseError(e.to_shared())
773            }
774        }
775    }
776}
777
778/// Parses a CSS path from a string (only the path,.no commas allowed)
779///
780/// ```rust
781/// # extern crate azul_css;
782/// # use azul_css::parser2::parse_css_path;
783/// # use azul_css::css::{
784/// #     CssPathSelector::*, CssPathPseudoSelector::*, CssPath,
785/// #     NodeTypeTag::*, CssNthChildSelector::*
786/// # };
787///
788/// assert_eq!(
789///     parse_css_path("* div #my_id > .class:nth-child(2)"),
790///     Ok(CssPath {
791///         selectors: vec![
792///             Global,
793///             Type(Div),
794///             Children,
795///             Id("my_id".to_string().into()),
796///             DirectChildren,
797///             Class("class".to_string().into()),
798///             PseudoSelector(NthChild(Number(2))),
799///         ]
800///         .into()
801///     })
802/// );
803/// ```
804/// # Errors
805///
806/// Returns an error if `input` is not a valid CSS `css-path` value.
807pub fn parse_css_path(input: &str) -> Result<CssPath, CssPathParseError<'_>> {
808    use azul_simplecss::{Combinator, Token};
809
810    let input = input.trim();
811    if input.is_empty() {
812        return Err(CssPathParseError::EmptyPath);
813    }
814
815    let mut tokenizer = Tokenizer::new(input);
816    let mut selectors = Vec::new();
817
818    loop {
819        let token = tokenizer.parse_next()?;
820        match token {
821            Token::UniversalSelector => {
822                selectors.push(CssPathSelector::Global);
823            }
824            Token::TypeSelector(div_type) => match NodeTypeTag::from_str(div_type) {
825                // An unknown type selector must invalidate the whole path (Selectors L4:
826                // an invalid simple selector invalidates the selector), not be silently
827                // dropped — dropping it left a dangling combinator that matched every
828                // descendant of the previous selector.
829                Ok(nt) => selectors.push(CssPathSelector::Type(nt)),
830                Err(e) => return Err(CssPathParseError::NodeTypeTag(e)),
831            },
832            Token::IdSelector(id) => {
833                selectors.push(CssPathSelector::Id(id.to_string().into()));
834            }
835            Token::ClassSelector(class) => {
836                selectors.push(CssPathSelector::Class(class.to_string().into()));
837            }
838            Token::Combinator(Combinator::GreaterThan) => {
839                selectors.push(CssPathSelector::DirectChildren);
840            }
841            Token::Combinator(Combinator::Space) => {
842                selectors.push(CssPathSelector::Children);
843            }
844            Token::Combinator(Combinator::Plus) => {
845                selectors.push(CssPathSelector::AdjacentSibling);
846            }
847            Token::Combinator(Combinator::Tilde) => {
848                selectors.push(CssPathSelector::GeneralSibling);
849            }
850            Token::PseudoClass { selector, value } => {
851                selectors.push(CssPathSelector::PseudoSelector(pseudo_selector_from_str(
852                    selector, value,
853                )?));
854            }
855            Token::EndOfStream => {
856                break;
857            }
858            _ => {
859                return Err(CssPathParseError::InvalidTokenEncountered(input));
860            }
861        }
862    }
863
864    if selectors.is_empty() {
865        Err(CssPathParseError::EmptyPath)
866    } else {
867        Ok(CssPath {
868            selectors: selectors.into(),
869        })
870    }
871}
872
873#[derive(Debug, Clone, PartialEq, Eq)]
874pub struct UnparsedCssRuleBlock<'a> {
875    /// The css path (full selector) of the style ruleset
876    pub path: CssPath,
877    /// `"justify-content" => "center"`
878    pub declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
879    /// Conditions from enclosing @-rules (@media, @lang, etc.)
880    pub conditions: Vec<DynamicSelector>,
881}
882
883/// Owned version of `UnparsedCssRuleBlock`, with `BTreeMap` of Strings.
884#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct UnparsedCssRuleBlockOwned {
886    pub path: CssPath,
887    pub declarations: BTreeMap<String, (String, ErrorLocationRange)>,
888    pub conditions: Vec<DynamicSelector>,
889}
890
891impl UnparsedCssRuleBlock<'_> {
892    #[must_use] pub fn to_contained(&self) -> UnparsedCssRuleBlockOwned {
893        UnparsedCssRuleBlockOwned {
894            path: self.path.clone(),
895            declarations: self
896                .declarations
897                .iter()
898                .map(|(k, (v, loc))| ((*k).to_string(), ((*v).to_string(), *loc)))
899                .collect(),
900            conditions: self.conditions.clone(),
901        }
902    }
903}
904
905impl UnparsedCssRuleBlockOwned {
906    #[must_use] pub fn to_shared(&self) -> UnparsedCssRuleBlock<'_> {
907        UnparsedCssRuleBlock {
908            path: self.path.clone(),
909            declarations: self
910                .declarations
911                .iter()
912                .map(|(k, (v, loc))| (k.as_str(), (v.as_str(), *loc)))
913                .collect(),
914            conditions: self.conditions.clone(),
915        }
916    }
917}
918
919#[derive(Debug, Clone, PartialEq)]
920pub struct CssParseWarnMsg<'a> {
921    pub warning: CssParseWarnMsgInner<'a>,
922    pub location: ErrorLocationRange,
923}
924
925/// Owned version of `CssParseWarnMsg`, where warning is the owned type.
926#[derive(Debug, Clone, PartialEq)]
927pub struct CssParseWarnMsgOwned {
928    pub warning: CssParseWarnMsgInnerOwned,
929    pub location: ErrorLocationRange,
930}
931
932impl CssParseWarnMsg<'_> {
933    #[must_use] pub fn to_contained(&self) -> CssParseWarnMsgOwned {
934        CssParseWarnMsgOwned {
935            warning: self.warning.to_contained(),
936            location: self.location,
937        }
938    }
939}
940
941impl CssParseWarnMsgOwned {
942    #[must_use] pub fn to_shared(&self) -> CssParseWarnMsg<'_> {
943        CssParseWarnMsg {
944            warning: self.warning.to_shared(),
945            location: self.location,
946        }
947    }
948}
949
950#[derive(Debug, Clone, PartialEq)]
951pub enum CssParseWarnMsgInner<'a> {
952    /// Key "blah" isn't (yet) supported, so the parser didn't attempt to parse the value at all
953    UnsupportedKeyValuePair { key: &'a str, value: &'a str },
954    /// A CSS parse error that was encountered but recovered from
955    ParseError(CssParseErrorInner<'a>),
956    /// A rule was skipped due to an error
957    SkippedRule {
958        selector: Option<&'a str>,
959        error: CssParseErrorInner<'a>,
960    },
961    /// A declaration was skipped due to an error
962    SkippedDeclaration {
963        key: &'a str,
964        value: &'a str,
965        error: CssParseErrorInner<'a>,
966    },
967    /// Malformed block structure (mismatched braces, etc.)
968    MalformedStructure { message: &'a str },
969}
970
971#[derive(Debug, Clone, PartialEq)]
972pub enum CssParseWarnMsgInnerOwned {
973    UnsupportedKeyValuePair {
974        key: String,
975        value: String,
976    },
977    ParseError(CssParseErrorInnerOwned),
978    SkippedRule {
979        selector: Option<String>,
980        error: CssParseErrorInnerOwned,
981    },
982    SkippedDeclaration {
983        key: String,
984        value: String,
985        error: CssParseErrorInnerOwned,
986    },
987    MalformedStructure {
988        message: String,
989    },
990}
991
992impl CssParseWarnMsgInner<'_> {
993    #[must_use] pub fn to_contained(&self) -> CssParseWarnMsgInnerOwned {
994        match self {
995            Self::UnsupportedKeyValuePair { key, value } => {
996                CssParseWarnMsgInnerOwned::UnsupportedKeyValuePair {
997                    key: (*key).to_string(),
998                    value: (*value).to_string(),
999                }
1000            }
1001            Self::ParseError(e) => CssParseWarnMsgInnerOwned::ParseError(e.to_contained()),
1002            Self::SkippedRule { selector, error } => CssParseWarnMsgInnerOwned::SkippedRule {
1003                selector: selector.map(std::string::ToString::to_string),
1004                error: error.to_contained(),
1005            },
1006            Self::SkippedDeclaration { key, value, error } => {
1007                CssParseWarnMsgInnerOwned::SkippedDeclaration {
1008                    key: (*key).to_string(),
1009                    value: (*value).to_string(),
1010                    error: error.to_contained(),
1011                }
1012            }
1013            Self::MalformedStructure { message } => CssParseWarnMsgInnerOwned::MalformedStructure {
1014                message: (*message).to_string(),
1015            },
1016        }
1017    }
1018}
1019
1020impl CssParseWarnMsgInnerOwned {
1021    #[must_use] pub fn to_shared(&self) -> CssParseWarnMsgInner<'_> {
1022        match self {
1023            Self::UnsupportedKeyValuePair { key, value } => {
1024                CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value }
1025            }
1026            Self::ParseError(e) => CssParseWarnMsgInner::ParseError(e.to_shared()),
1027            Self::SkippedRule { selector, error } => CssParseWarnMsgInner::SkippedRule {
1028                selector: selector.as_deref(),
1029                error: error.to_shared(),
1030            },
1031            Self::SkippedDeclaration { key, value, error } => {
1032                CssParseWarnMsgInner::SkippedDeclaration {
1033                    key,
1034                    value,
1035                    error: error.to_shared(),
1036                }
1037            }
1038            Self::MalformedStructure { message } => {
1039                CssParseWarnMsgInner::MalformedStructure { message }
1040            }
1041        }
1042    }
1043}
1044
1045impl_display! { CssParseWarnMsgInner<'a>, {
1046    UnsupportedKeyValuePair { key, value } => format!("Unsupported CSS property: \"{}: {}\"", key, value),
1047    ParseError(e) => format!("Parse error (recoverable): {}", e),
1048    SkippedRule { selector, error } => {
1049        let sel = selector.unwrap_or("unknown");
1050        format!("Skipped rule for selector '{sel}': {error}")
1051    },
1052    SkippedDeclaration { key, value, error } => format!("Skipped declaration '{}:{}': {}", key, value, error),
1053    MalformedStructure { message } => format!("Malformed CSS structure: {}", message),
1054}}
1055
1056/// Parses @media conditions from the content following "@media"
1057/// Returns a list of `DynamicSelectors` for the conditions
1058fn parse_media_conditions(content: &str) -> Vec<DynamicSelector> {
1059    let mut conditions = Vec::new();
1060    let content = content.trim();
1061
1062    // Handle simple media types: "screen", "print", "all"
1063    if content.eq_ignore_ascii_case("screen") {
1064        conditions.push(DynamicSelector::Media(MediaType::Screen));
1065        return conditions;
1066    }
1067    if content.eq_ignore_ascii_case("print") {
1068        conditions.push(DynamicSelector::Media(MediaType::Print));
1069        return conditions;
1070    }
1071    if content.eq_ignore_ascii_case("all") {
1072        conditions.push(DynamicSelector::Media(MediaType::All));
1073        return conditions;
1074    }
1075
1076    // Parse more complex media queries like "(min-width: 800px)" or "screen and (max-width: 600px)"
1077    // Split by "and" for compound queries
1078    for part in content.split(" and ") {
1079        let part = part.trim();
1080
1081        // Skip media type keywords in compound queries
1082        if part.eq_ignore_ascii_case("screen")
1083            || part.eq_ignore_ascii_case("print")
1084            || part.eq_ignore_ascii_case("all")
1085        {
1086            if part.eq_ignore_ascii_case("screen") {
1087                conditions.push(DynamicSelector::Media(MediaType::Screen));
1088            } else if part.eq_ignore_ascii_case("print") {
1089                conditions.push(DynamicSelector::Media(MediaType::Print));
1090            } else if part.eq_ignore_ascii_case("all") {
1091                conditions.push(DynamicSelector::Media(MediaType::All));
1092            }
1093            continue;
1094        }
1095
1096        // Parse parenthesized conditions like "(min-width: 800px)"
1097        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1098            if let Some(selector) = parse_media_feature(inner) {
1099                conditions.push(selector);
1100            }
1101        }
1102    }
1103
1104    conditions
1105}
1106
1107/// Parses a single media feature like "min-width: 800px"
1108fn parse_media_feature(feature: &str) -> Option<DynamicSelector> {
1109    let parts: Vec<&str> = feature.splitn(2, ':').collect();
1110    if parts.len() != 2 {
1111        // Handle features without values like "orientation: portrait"
1112        return None;
1113    }
1114
1115    let key = parts[0].trim();
1116    let value = parts[1].trim();
1117
1118    match key.to_lowercase().as_str() {
1119        "min-width" => {
1120            if let Some(px) = parse_px_value(value) {
1121                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
1122                    Some(px),
1123                    None,
1124                )));
1125            }
1126        }
1127        "max-width" => {
1128            if let Some(px) = parse_px_value(value) {
1129                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
1130                    None,
1131                    Some(px),
1132                )));
1133            }
1134        }
1135        "min-height" => {
1136            if let Some(px) = parse_px_value(value) {
1137                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
1138                    Some(px),
1139                    None,
1140                )));
1141            }
1142        }
1143        "max-height" => {
1144            if let Some(px) = parse_px_value(value) {
1145                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
1146                    None,
1147                    Some(px),
1148                )));
1149            }
1150        }
1151        "orientation" => {
1152            if value.eq_ignore_ascii_case("portrait") {
1153                return Some(DynamicSelector::Orientation(OrientationType::Portrait));
1154            } else if value.eq_ignore_ascii_case("landscape") {
1155                return Some(DynamicSelector::Orientation(OrientationType::Landscape));
1156            }
1157        }
1158        "prefers-color-scheme" => {
1159            if value.eq_ignore_ascii_case("dark") {
1160                return Some(DynamicSelector::Theme(ThemeCondition::Dark));
1161            } else if value.eq_ignore_ascii_case("light") {
1162                return Some(DynamicSelector::Theme(ThemeCondition::Light));
1163            }
1164        }
1165        "prefers-reduced-motion" => {
1166            if value.eq_ignore_ascii_case("reduce") {
1167                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True));
1168            } else if value.eq_ignore_ascii_case("no-preference") {
1169                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::False));
1170            }
1171        }
1172        "prefers-contrast" | "prefers-high-contrast" => {
1173            if value.eq_ignore_ascii_case("more") || value.eq_ignore_ascii_case("high") || value.eq_ignore_ascii_case("active") {
1174                return Some(DynamicSelector::PrefersHighContrast(BoolCondition::True));
1175            } else if value.eq_ignore_ascii_case("no-preference") || value.eq_ignore_ascii_case("none") {
1176                return Some(DynamicSelector::PrefersHighContrast(BoolCondition::False));
1177            }
1178        }
1179        "aspect-ratio" => {
1180            if let Some(ratio) = parse_ratio_value(value) {
1181                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), Some(ratio))));
1182            }
1183        }
1184        "min-aspect-ratio" => {
1185            if let Some(ratio) = parse_ratio_value(value) {
1186                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), None)));
1187            }
1188        }
1189        "max-aspect-ratio" => {
1190            if let Some(ratio) = parse_ratio_value(value) {
1191                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(ratio))));
1192            }
1193        }
1194        _ => {}
1195    }
1196
1197    None
1198}
1199
1200/// Parses a pixel value like "800px" and returns the numeric value
1201fn parse_px_value(value: &str) -> Option<f32> {
1202    let value = value.trim();
1203    value
1204        .strip_suffix("px")
1205        .map_or_else(
1206            // Try parsing as a bare number
1207            || value.parse::<f32>().ok(),
1208            |num_str| num_str.trim().parse::<f32>().ok(),
1209        )
1210        // `str::parse::<f32>` accepts "NaN"/"inf"/"infinity"; the CSS <number-token>
1211        // grammar does not (CSS Syntax L3 §4.3.6 — digits, no keywords). Letting a NaN
1212        // through is not merely lax: `MinMaxRange` encodes "no bound" AS NaN, so
1213        // `@media (min-width: NaN)` would silently become an unconditional match
1214        // instead of an invalid feature. Reject non-finite at the source.
1215        .filter(|v| v.is_finite())
1216}
1217
1218/// Parses a ratio value like "16/9" or "1.777" and returns it as f32
1219fn parse_ratio_value(value: &str) -> Option<f32> {
1220    let value = value.trim();
1221    if let Some((num, den)) = value.split_once('/') {
1222        let num: f32 = num.trim().parse().ok()?;
1223        let den: f32 = den.trim().parse().ok()?;
1224        if den == 0.0 { return None; }
1225        // Same NaN-sentinel hazard as parse_px_value: "inf/inf" and "1/NaN" both parse,
1226        // and a NaN ratio reads back out of MinMaxRange as "no bound".
1227        Some(num / den).filter(|r| r.is_finite())
1228    } else {
1229        value.parse::<f32>().ok().filter(|r| r.is_finite())
1230    }
1231}
1232
1233/// Parses @container conditions from the content following "@container"
1234/// Format: @container (min-width: 400px) or @container sidebar (min-width: 400px)
1235fn parse_container_conditions(content: &str) -> Vec<DynamicSelector> {
1236    let mut conditions = Vec::new();
1237    let content = content.trim();
1238
1239    // Check if there's a container name before the parenthesized condition
1240    // e.g., "sidebar (min-width: 400px)" or just "(min-width: 400px)"
1241    let (name_part, query_part) = if content.starts_with('(') {
1242        (None, content)
1243    } else if let Some(paren_idx) = content.find('(') {
1244        let name = content[..paren_idx].trim();
1245        if name.is_empty() {
1246            (None, content)
1247        } else {
1248            (Some(name), &content[paren_idx..])
1249        }
1250    } else {
1251        // No parentheses - might be just a container name
1252        if !content.is_empty() {
1253            conditions.push(DynamicSelector::ContainerName(AzString::from(content.to_string())));
1254        }
1255        return conditions;
1256    };
1257
1258    if let Some(name) = name_part {
1259        conditions.push(DynamicSelector::ContainerName(AzString::from(name.to_string())));
1260    }
1261
1262    // Parse the parenthesized query parts
1263    for part in query_part.split(" and ") {
1264        let part = part.trim();
1265        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1266            if let Some(selector) = parse_container_feature(inner) {
1267                conditions.push(selector);
1268            }
1269        }
1270    }
1271
1272    conditions
1273}
1274
1275/// Parses a single container query feature like "min-width: 400px"
1276fn parse_container_feature(feature: &str) -> Option<DynamicSelector> {
1277    let (key, value) = feature.split_once(':')?;
1278    let key = key.trim();
1279    let value = value.trim();
1280
1281    match key.to_lowercase().as_str() {
1282        "min-width" => {
1283            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(Some(px), None)))
1284        }
1285        "max-width" => {
1286            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(None, Some(px))))
1287        }
1288        "min-height" => {
1289            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(Some(px), None)))
1290        }
1291        "max-height" => {
1292            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(None, Some(px))))
1293        }
1294        "aspect-ratio" => {
1295            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), Some(r))))
1296        }
1297        "min-aspect-ratio" => {
1298            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), None)))
1299        }
1300        "max-aspect-ratio" => {
1301            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(r))))
1302        }
1303        _ => None,
1304    }
1305}
1306
1307/// Parses @theme condition from the content following "@theme"
1308/// Format: @theme(dark) or @theme dark
1309fn parse_theme_condition(content: &str) -> Option<DynamicSelector> {
1310    let content = content.trim();
1311    let inner = content
1312        .strip_prefix('(')
1313        .and_then(|s| s.strip_suffix(')'))
1314        .unwrap_or(content)
1315        .trim();
1316    let inner = inner
1317        .strip_prefix('"')
1318        .and_then(|s| s.strip_suffix('"'))
1319        .or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
1320        .unwrap_or(inner)
1321        .trim();
1322
1323    match inner.to_lowercase().as_str() {
1324        "dark" => Some(DynamicSelector::Theme(ThemeCondition::Dark)),
1325        "light" => Some(DynamicSelector::Theme(ThemeCondition::Light)),
1326        _ => None,
1327    }
1328}
1329
1330/// Parses @lang condition from the content following "@lang"
1331/// Format: @lang("de-DE") or @lang(de-DE)
1332fn parse_lang_condition(content: &str) -> Option<DynamicSelector> {
1333    let content = content.trim();
1334
1335    // Remove parentheses and quotes
1336    let lang = content
1337        .strip_prefix('(')
1338        .and_then(|s| s.strip_suffix(')'))
1339        .unwrap_or(content)
1340        .trim();
1341
1342    let lang = lang
1343        .strip_prefix('"')
1344        .and_then(|s| s.strip_suffix('"'))
1345        .or_else(|| lang.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
1346        .unwrap_or(lang)
1347        .trim();
1348
1349    if lang.is_empty() {
1350        return None;
1351    }
1352
1353    // Use Prefix matching by default (e.g., "de" matches "de-DE", "de-AT")
1354    Some(DynamicSelector::Language(LanguageCondition::Prefix(
1355        AzString::from(lang.to_string()),
1356    )))
1357}
1358
1359/// Parses a CSS string (single-threaded) and returns the parsed rules in blocks
1360///
1361/// May return "warning" messages, i.e. messages that just serve as a warning,
1362/// instead of being actual errors. These warnings may be ignored by the caller,
1363/// but can be useful for debugging.
1364// Beyond this CSS nesting depth, get_parent_paths clones the ever-growing
1365// ancestor path every level (parse becomes O(depth^2) — a hang on adversarial
1366// input like `div{` x 10_000), so deeper rules keep only their own local
1367// selector. No realistic stylesheet nests this deep; this bounds parse time.
1368const MAX_NESTING_DEPTH: usize = 1024;
1369
1370#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose CSS parser/formatter/dispatch table (one branch per property/variant)
1371fn new_from_str_inner<'a>(
1372    css_string: &'a str,
1373    tokenizer: &mut Tokenizer<'a>,
1374) -> (Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>) {
1375    use azul_simplecss::{Combinator, Token};
1376
1377    // Stack entry for nested selectors: accumulated parent paths + the current
1378    // declarations at this nesting level.
1379    struct NestingLevel<'a> {
1380        paths: Vec<Vec<CssPathSelector>>,
1381        declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
1382        depth: usize,
1383    }
1384
1385    // Helper: get parent paths from nesting stack (if any)
1386    fn get_parent_paths(nesting_stack: &[NestingLevel<'_>]) -> Vec<Vec<CssPathSelector>> {
1387        nesting_stack
1388            .last()
1389            .map_or_else(Vec::new, |parent| parent.paths.clone())
1390    }
1391
1392    // Helper: combine parent path with child selector for nesting
1393    // For .button { :hover { } } -> .button:hover
1394    // For .outer { .inner { } } -> .outer .inner (with Children combinator)
1395    fn combine_paths(
1396        parent_paths: &[Vec<CssPathSelector>],
1397        child_path: &[CssPathSelector],
1398        is_pseudo_only: bool,
1399    ) -> Vec<Vec<CssPathSelector>> {
1400        if parent_paths.is_empty() {
1401            vec![child_path.to_vec()]
1402        } else {
1403            parent_paths
1404                .iter()
1405                .map(|parent| {
1406                    let mut combined = parent.clone();
1407                    if !is_pseudo_only && !child_path.is_empty() {
1408                        // Add implicit descendant combinator for non-pseudo selectors
1409                        combined.push(CssPathSelector::Children);
1410                    }
1411                    combined.extend(child_path.iter().cloned());
1412                    combined
1413                })
1414                .collect()
1415        }
1416    }
1417
1418    let mut css_blocks = Vec::new();
1419    let mut warnings = Vec::new();
1420
1421    let mut block_nesting = 0_usize;
1422    let mut last_path: Vec<CssPathSelector> = Vec::new();
1423    let mut last_error_location = ErrorLocation { original_pos: 0 };
1424
1425    // Stack for tracking @-rule conditions (e.g., @media, @lang, @os)
1426    // Each entry contains the conditions and the nesting level where they were introduced
1427    let mut at_rule_stack: Vec<(Vec<DynamicSelector>, usize)> = Vec::new();
1428    // Pending @-rule that needs to be combined with AtStr tokens
1429    let mut pending_at_rule: Option<&str> = None;
1430    // Collect multiple AtStr tokens (e.g., "screen", "(min-width: 800px)" for compound media queries)
1431    let mut pending_at_str_parts: Vec<String> = Vec::new();
1432
1433    // Stack for nested selectors
1434    // Each entry: (parent_paths, declarations, nesting_level)
1435    // parent_paths: all accumulated paths at this level (for comma-separated selectors)
1436    // declarations: current declarations at this level
1437    let mut nesting_stack: Vec<NestingLevel<'a>> = Vec::new();
1438    // Current accumulated paths before BlockStart
1439    let mut current_paths: Vec<Vec<CssPathSelector>> = Vec::new();
1440    // Current declarations at current level
1441    let mut current_declarations: BTreeMap<&str, (&str, ErrorLocationRange)> = BTreeMap::new();
1442
1443    // Safety: limit maximum iterations to prevent infinite loops
1444    // A reasonable limit is 10x the input length (each char could produce at most a few tokens)
1445    let max_iterations = css_string.len().saturating_mul(10).max(1000);
1446    let mut iterations = 0_usize;
1447    let mut last_position = 0_usize;
1448    let mut stuck_count = 0_usize;
1449
1450    loop {
1451        // Safety check 1: Maximum iterations
1452        iterations += 1;
1453        if iterations > max_iterations {
1454            warnings.push(CssParseWarnMsg {
1455                warning: CssParseWarnMsgInner::MalformedStructure {
1456                    message: "Parser iteration limit exceeded - possible infinite loop",
1457                },
1458                location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
1459            });
1460            break;
1461        }
1462
1463        // Safety check 2: Detect if parser is stuck (position not advancing)
1464        let current_position = tokenizer.pos();
1465        if current_position == last_position {
1466            stuck_count += 1;
1467            if stuck_count > 10 {
1468                warnings.push(CssParseWarnMsg {
1469                    warning: CssParseWarnMsgInner::MalformedStructure {
1470                        message: "Parser stuck - position not advancing",
1471                    },
1472                    location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
1473                });
1474                break;
1475            }
1476        } else {
1477            stuck_count = 0;
1478            last_position = current_position;
1479        }
1480
1481        let token = match tokenizer.parse_next() {
1482            Ok(token) => token,
1483            Err(e) => {
1484                let error_location = get_error_location(tokenizer);
1485                // An unclosed block that still contains a declaration makes the
1486                // tokenizer raise UnexpectedEndOfStream while scanning past the last `;`
1487                // for the missing `}`, BEFORE the loop ever reaches Token::EndOfStream.
1488                // Emit the same dedicated "unclosed blocks" diagnostic that arm would,
1489                // rather than a generic parse error, when we're still inside a block.
1490                let warning = if block_nesting != 0 {
1491                    CssParseWarnMsgInner::MalformedStructure {
1492                        message: "Unclosed blocks at end of file",
1493                    }
1494                } else {
1495                    CssParseWarnMsgInner::ParseError(e.into())
1496                };
1497                warnings.push(CssParseWarnMsg {
1498                    warning,
1499                    location: ErrorLocationRange { start: last_error_location, end: error_location },
1500                });
1501                // On error, break to avoid infinite loop - the tokenizer may be stuck
1502                break;
1503            }
1504        };
1505
1506        macro_rules! warn_and_continue {
1507            ($warning:expr) => {{
1508                warnings.push(CssParseWarnMsg {
1509                    warning: $warning,
1510                    location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
1511                });
1512                continue;
1513            }};
1514        }
1515
1516        match token {
1517            Token::AtRule(rule_name) => {
1518                // Store the @-rule name to combine with the following AtStr tokens
1519                pending_at_rule = Some(rule_name);
1520                pending_at_str_parts.clear();
1521            }
1522            Token::AtStr(content) => {
1523                // Collect AtStr tokens until we see BlockStart
1524                if pending_at_rule.is_some() {
1525                    // Skip "and" keyword, it's just a separator
1526                    if !content.eq_ignore_ascii_case("and") {
1527                        pending_at_str_parts.push(content.to_string());
1528                    }
1529                }
1530            }
1531            Token::BlockStart => {
1532                // Process pending @-rule with all collected AtStr parts
1533                if let Some(rule_name) = pending_at_rule.take() {
1534                    let combined_content = pending_at_str_parts.join(" and ");
1535                    pending_at_str_parts.clear();
1536                    
1537                    let conditions = match rule_name.to_lowercase().as_str() {
1538                        "media" => parse_media_conditions(&combined_content),
1539                        "lang" => parse_lang_condition(&combined_content).into_iter().collect(),
1540                        "os" => crate::dynamic_selector::parse_os_at_rule_content(&combined_content).unwrap_or_default(),
1541                        "theme" => parse_theme_condition(&combined_content).into_iter().collect(),
1542                        "container" => parse_container_conditions(&combined_content),
1543                        _ => {
1544                            // Unknown @-rule, ignore
1545                            Vec::new()
1546                        }
1547                    };
1548
1549                    if !conditions.is_empty() {
1550                        // Push conditions to stack, will be applied to nested rules
1551                        at_rule_stack.push((conditions, block_nesting + 1));
1552                    }
1553                }
1554
1555                block_nesting += 1;
1556
1557                // If we have a selector, push current state onto nesting stack
1558                if !current_paths.is_empty() || !last_path.is_empty() {
1559                    // Finalize current_paths with last_path
1560                    if !last_path.is_empty() {
1561                        current_paths.push(last_path.clone());
1562                        last_path.clear();
1563                    }
1564
1565                    // Get parent paths and combine with current paths. Beyond
1566                    // MAX_NESTING_DEPTH, stop combining with the ancestor chain to
1567                    // bound the O(depth^2) path-cloning (see the const's doc above).
1568                    let combined_paths: Vec<Vec<CssPathSelector>> = if block_nesting > MAX_NESTING_DEPTH {
1569                        std::mem::take(&mut current_paths)
1570                    } else {
1571                        let parent_paths = get_parent_paths(&nesting_stack);
1572                        if parent_paths.is_empty() {
1573                            current_paths.clone()
1574                        } else {
1575                            // Combine each parent path with each current path
1576                            let mut result = Vec::new();
1577                            for parent in &parent_paths {
1578                                for child in &current_paths {
1579                                    // Check if child starts with pseudo-selector
1580                                    let is_pseudo_only = child.first().is_some_and(|s| matches!(s, CssPathSelector::PseudoSelector(_)));
1581                                    let mut combined = parent.clone();
1582                                    if !is_pseudo_only && !child.is_empty() {
1583                                        combined.push(CssPathSelector::Children);
1584                                    }
1585                                    combined.extend(child.iter().cloned());
1586                                    result.push(combined);
1587                                }
1588                            }
1589                            result
1590                        }
1591                    };
1592
1593                    // Push to nesting stack
1594                    nesting_stack.push(NestingLevel {
1595                        paths: combined_paths,
1596                        declarations: std::mem::take(&mut current_declarations),
1597                        depth: block_nesting,
1598                    });
1599                    current_paths.clear();
1600                }
1601            }
1602            Token::Comma => {
1603                // Comma separates selectors
1604                if !last_path.is_empty() {
1605                    current_paths.push(last_path.clone());
1606                    last_path.clear();
1607                }
1608            }
1609            Token::BlockEnd => {
1610                if block_nesting == 0 {
1611                    warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
1612                        message: "Block end without matching block start"
1613                    });
1614                }
1615
1616                // Collect all conditions from the current @-rule stack
1617                let current_conditions: Vec<DynamicSelector> = at_rule_stack
1618                    .iter()
1619                    .flat_map(|(conds, _)| conds.iter().cloned())
1620                    .collect();
1621
1622                // Pop @-rule conditions that are at this nesting level
1623                while let Some((_, level)) = at_rule_stack.last() {
1624                    if *level >= block_nesting {
1625                        at_rule_stack.pop();
1626                    } else {
1627                        break;
1628                    }
1629                }
1630
1631                block_nesting = block_nesting.saturating_sub(1);
1632
1633                // Pop from nesting stack if we have one
1634                if let Some(level) = nesting_stack.pop() {
1635                    // Emit CSS blocks for all paths at this level
1636                    if !level.paths.is_empty() && !current_declarations.is_empty() {
1637                        css_blocks.extend(level.paths.iter().map(|path| UnparsedCssRuleBlock {
1638                            path: CssPath {
1639                                selectors: path.clone().into(),
1640                            },
1641                            declarations: current_declarations.clone(),
1642                            conditions: current_conditions.clone(),
1643                        }));
1644                    }
1645                    // Restore parent declarations
1646                    current_declarations = level.declarations;
1647                }
1648
1649                last_path.clear();
1650                current_paths.clear();
1651            }
1652            Token::UniversalSelector => {
1653                last_path.push(CssPathSelector::Global);
1654            }
1655            Token::TypeSelector(div_type) => {
1656                match NodeTypeTag::from_str(div_type) {
1657                    Ok(nt) => last_path.push(CssPathSelector::Type(nt)),
1658                    Err(e) => {
1659                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
1660                            selector: Some(div_type),
1661                            error: e.into(),
1662                        });
1663                    }
1664                }
1665            }
1666            Token::IdSelector(id) => {
1667                last_path.push(CssPathSelector::Id(id.to_string().into()));
1668            }
1669            Token::ClassSelector(class) => {
1670                last_path.push(CssPathSelector::Class(class.to_string().into()));
1671            }
1672            Token::Combinator(Combinator::GreaterThan) => {
1673                last_path.push(CssPathSelector::DirectChildren);
1674            }
1675            Token::Combinator(Combinator::Space) => {
1676                last_path.push(CssPathSelector::Children);
1677            }
1678            Token::Combinator(Combinator::Plus) => {
1679                last_path.push(CssPathSelector::AdjacentSibling);
1680            }
1681            Token::Combinator(Combinator::Tilde) => {
1682                last_path.push(CssPathSelector::GeneralSibling);
1683            }
1684            Token::PseudoClass { selector, value } | Token::DoublePseudoClass { selector, value } => {
1685                match pseudo_selector_from_str(selector, value) {
1686                    Ok(ps) => last_path.push(CssPathSelector::PseudoSelector(ps)),
1687                    Err(e) => {
1688                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
1689                            selector: Some(selector),
1690                            error: e.into(),
1691                        });
1692                    }
1693                }
1694            }
1695            Token::AttributeSelector(attr) => {
1696                if let Some(sel) = parse_attribute_selector(attr) { last_path.push(CssPathSelector::Attribute(sel)) } else { warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
1697                    message: "Malformed attribute selector, rule skipped",
1698                }) }
1699            }
1700            Token::Declaration(key, val) => {
1701                current_declarations.insert(
1702                    key,
1703                    (val, ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) }),
1704                );
1705            }
1706            Token::EndOfStream => {
1707                if block_nesting != 0 {
1708                    warnings.push(CssParseWarnMsg {
1709                        warning: CssParseWarnMsgInner::MalformedStructure {
1710                            message: "Unclosed blocks at end of file",
1711                        },
1712                        location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
1713                    });
1714                }
1715                break;
1716            }
1717            _ => { /* Ignore unsupported tokens */ }
1718        }
1719
1720        last_error_location = get_error_location(tokenizer);
1721    }
1722
1723    // Process the collected CSS blocks and convert warnings
1724    let (stylesheet, mut block_warnings) = css_blocks_to_stylesheet(css_blocks, css_string);
1725    warnings.append(&mut block_warnings);
1726
1727    (stylesheet, warnings)
1728}
1729
1730/// Resolves a parsed `var(--name)` reference (a `CssDeclaration::Dynamic`) against the
1731/// document-wide custom-property map, producing a concrete `Static` declaration.
1732///
1733/// If the referenced custom property is defined, its raw value is parsed as the referenced
1734/// property's type (taken from the `var()`'s parsed fallback). Otherwise — undefined `var()`
1735/// or an unparseable value — the fallback (`default_value`) is used, matching the CSS
1736/// behaviour of an invalid/guaranteed-invalid substitution falling back to the declared
1737/// default. Non-`Dynamic` declarations pass through unchanged.
1738fn resolve_var_reference(
1739    decl: CssDeclaration,
1740    custom_props: &BTreeMap<String, String>,
1741) -> CssDeclaration {
1742    let CssDeclaration::Dynamic(dyn_prop) = decl else {
1743        return decl;
1744    };
1745    // `dynamic_id` is stored without the leading `--`; trim defensively either way.
1746    let name = dyn_prop.dynamic_id.as_str().trim_start_matches("--");
1747    if let Some(raw) = custom_props.get(name) {
1748        if let Ok(parsed) = parse_css_property(dyn_prop.default_value.get_type(), raw) {
1749            return CssDeclaration::Static(parsed);
1750        }
1751    }
1752    CssDeclaration::Static(dyn_prop.default_value)
1753}
1754
1755fn css_blocks_to_stylesheet<'a>(
1756    css_blocks: Vec<UnparsedCssRuleBlock<'a>>,
1757    css_string: &'a str,
1758) -> (Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>) {
1759    let css_key_map = crate::props::property::get_css_key_map();
1760    let mut warnings = Vec::new();
1761    let mut parsed_css_blocks = Vec::new();
1762
1763    // CSS custom properties (`--name: value`) + `var()` references. The parser already turns
1764    // `prop: var(--name, fallback)` into a `CssDeclaration::Dynamic`, but nothing consumed it
1765    // and `--name` definitions were dropped as unknown keys. Resolve them here at parse time:
1766    // collect every `--name` definition document-wide, then substitute each var() reference
1767    // with the referenced value (parsed as the target property's type) or its fallback. This
1768    // is a pragmatic subset of the full cascade — it covers the common `:root { --x: ... }`
1769    // pattern; element-scoped custom properties (redefined per subtree) are not modelled, which
1770    // would require cascade-level storage. Keys are stored without the leading `--`.
1771    let mut custom_props: BTreeMap<String, String> = BTreeMap::new();
1772    for block in &css_blocks {
1773        for (key, (value, _)) in &block.declarations {
1774            if let Some(name) = key.strip_prefix("--") {
1775                custom_props.insert(name.to_string(), value.trim().to_string());
1776            }
1777        }
1778    }
1779
1780    for unparsed_css_block in css_blocks {
1781        let mut declarations = Vec::<CssDeclaration>::new();
1782
1783        for (unparsed_css_key, (unparsed_css_value, location)) in &unparsed_css_block.declarations {
1784            // Custom-property DEFINITIONS were collected above; they emit no declaration
1785            // themselves (and must not warn as unknown keys).
1786            if unparsed_css_key.starts_with("--") {
1787                continue;
1788            }
1789            match parse_declaration_resilient(
1790                unparsed_css_key,
1791                unparsed_css_value,
1792                *location,
1793                &css_key_map,
1794            ) {
1795                Ok(decls) => {
1796                    declarations.extend(decls.into_iter().map(|d| resolve_var_reference(d, &custom_props)));
1797                }
1798                Err(e) => {
1799                    warnings.push(CssParseWarnMsg {
1800                        warning: CssParseWarnMsgInner::SkippedDeclaration {
1801                            key: unparsed_css_key,
1802                            value: unparsed_css_value,
1803                            error: e,
1804                        },
1805                        location: *location,
1806                    });
1807                }
1808            }
1809        }
1810
1811        parsed_css_blocks.push(CssRuleBlock {
1812            path: unparsed_css_block.path,
1813            declarations: declarations.into(),
1814            conditions: unparsed_css_block.conditions.into(),
1815            priority: crate::css::rule_priority::AUTHOR,
1816        });
1817    }
1818
1819    (parsed_css_blocks, warnings)
1820}
1821
1822fn parse_declaration_resilient<'a>(
1823    unparsed_css_key: &'a str,
1824    unparsed_css_value: &'a str,
1825    location: ErrorLocationRange,
1826    css_key_map: &CssKeyMap,
1827) -> Result<Vec<CssDeclaration>, CssParseErrorInner<'a>> {
1828    let mut declarations = Vec::new();
1829
1830    if let Some(combined_key) = CombinedCssPropertyType::from_str(unparsed_css_key, css_key_map) {
1831        if check_if_value_is_css_var(unparsed_css_value).is_some() {
1832            return Err(CssParseErrorInner::VarOnShorthandProperty {
1833                key: combined_key,
1834                value: unparsed_css_value,
1835            });
1836        }
1837
1838        // Attempt to parse combined properties, continue with what succeeds
1839        match parse_combined_css_property(combined_key, unparsed_css_value) {
1840            Ok(parsed_props) => {
1841                declarations.extend(parsed_props.into_iter().map(CssDeclaration::Static));
1842            }
1843            Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
1844        }
1845    } else if let Some(normal_key) = CssPropertyType::from_str(unparsed_css_key, css_key_map) {
1846        if let Some(css_var) = check_if_value_is_css_var(unparsed_css_value) {
1847            let (css_var_id, css_var_default) = css_var?;
1848            match parse_css_property(normal_key, css_var_default) {
1849                Ok(parsed_default) => {
1850                    declarations.push(CssDeclaration::Dynamic(DynamicCssProperty {
1851                        dynamic_id: css_var_id.to_string().into(),
1852                        default_value: parsed_default,
1853                    }));
1854                }
1855                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
1856            }
1857        } else {
1858            match parse_css_property(normal_key, unparsed_css_value) {
1859                Ok(parsed_value) => {
1860                    declarations.push(CssDeclaration::Static(parsed_value));
1861                }
1862                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
1863            }
1864        }
1865    } else {
1866        return Err(CssParseErrorInner::UnknownPropertyKey(
1867            unparsed_css_key,
1868            unparsed_css_value,
1869        ));
1870    }
1871
1872    Ok(declarations)
1873}
1874
1875/// Parses a single CSS key-value declaration, appending results to `declarations`.
1876///
1877/// Unknown property keys are downgraded to warnings (pushed into `warnings`)
1878/// rather than causing a hard error, so callers can continue processing the
1879/// remaining declarations in a rule block.
1880/// # Errors
1881///
1882/// Returns an error if `input` is not a valid CSS `css-declaration` value.
1883pub fn parse_css_declaration<'a>(
1884    unparsed_css_key: &'a str,
1885    unparsed_css_value: &'a str,
1886    location: ErrorLocationRange,
1887    css_key_map: &CssKeyMap,
1888    warnings: &mut Vec<CssParseWarnMsg<'a>>,
1889    declarations: &mut Vec<CssDeclaration>,
1890) -> Result<(), CssParseErrorInner<'a>> {
1891    match parse_declaration_resilient(unparsed_css_key, unparsed_css_value, location, css_key_map) {
1892        Ok(mut decls) => {
1893            declarations.append(&mut decls);
1894            Ok(())
1895        }
1896        Err(e) => {
1897            if let CssParseErrorInner::UnknownPropertyKey(key, val) = &e {
1898                warnings.push(CssParseWarnMsg {
1899                    warning: CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value: val },
1900                    location,
1901                });
1902                Ok(()) // Continue processing despite unknown property
1903            } else {
1904                Err(e) // Propagate other errors
1905            }
1906        }
1907    }
1908}
1909
1910fn check_if_value_is_css_var(
1911    unparsed_css_value: &str,
1912) -> Option<Result<(&str, &str), CssParseErrorInner<'_>>> {
1913    const DEFAULT_VARIABLE_DEFAULT: &str = "none";
1914
1915    let (_, brace_contents) = parse_parentheses(unparsed_css_value, &["var"]).ok()?;
1916
1917    // value is a CSS variable, i.e. var(--main-bg-color)
1918    Some(match parse_css_variable_brace_contents(brace_contents) {
1919        Some((variable_id, default_value)) => Ok((
1920            variable_id,
1921            default_value.unwrap_or(DEFAULT_VARIABLE_DEFAULT),
1922        )),
1923        None => Err(DynamicCssParseError::InvalidBraceContents(brace_contents).into()),
1924    })
1925}
1926
1927/// Parses the brace contents of a css var, i.e.:
1928///
1929/// ```no_run,ignore
1930/// "--main-bg-col, blue" => (Some("main-bg-col"), Some("blue"))
1931/// "--main-bg-col"       => (Some("main-bg-col"), None)
1932/// ```
1933fn parse_css_variable_brace_contents(input: &str) -> Option<(&str, Option<&str>)> {
1934    let input = input.trim();
1935
1936    let mut split_comma_iter = input.splitn(2, ',');
1937    let var_name = split_comma_iter.next()?;
1938    let var_name = var_name.trim();
1939
1940    if !var_name.starts_with("--") {
1941        return None; // no proper CSS variable name
1942    }
1943
1944    Some((&var_name[2..], split_comma_iter.next()))
1945}
1946
1947#[cfg(test)]
1948#[allow(
1949    clippy::all,
1950    clippy::pedantic,
1951    clippy::nursery,
1952    unused_qualifications,
1953    single_use_lifetimes
1954)]
1955mod autotest_generated {
1956    use super::*;
1957    use crate::css::CssNthChildPattern;
1958
1959    // ---------------------------------------------------------------------
1960    // helpers
1961    // ---------------------------------------------------------------------
1962
1963    /// Runs `f`, converting a panic into `Err(message)` so that a *panicking*
1964    /// function under test produces a readable assertion failure instead of
1965    /// tearing down the test binary. `[profile.test] panic = "unwind"` is set
1966    /// in the workspace root `Cargo.toml`, so unwinding is available here.
1967    fn catch<R>(f: impl FnOnce() -> R) -> Result<R, String> {
1968        std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
1969            e.downcast_ref::<String>().cloned().unwrap_or_else(|| {
1970                e.downcast_ref::<&str>()
1971                    .map_or_else(|| "<non-string panic payload>".to_string(), |s| (*s).to_string())
1972            })
1973        })
1974    }
1975
1976    fn key_map() -> CssKeyMap {
1977        crate::props::property::get_css_key_map()
1978    }
1979
1980    fn loc(start: usize, end: usize) -> ErrorLocationRange {
1981        ErrorLocationRange {
1982            start: ErrorLocation { original_pos: start },
1983            end: ErrorLocation { original_pos: end },
1984        }
1985    }
1986
1987    /// A grab-bag of hostile inputs reused across the string parsers.
1988    const HOSTILE: &[&str] = &[
1989        "",
1990        " ",
1991        "   \t\n\r  ",
1992        "\0",
1993        "\u{1F600}",
1994        "e\u{301}\u{301}\u{301}",
1995        "-0",
1996        "0",
1997        "NaN",
1998        "inf",
1999        "-inf",
2000        "9223372036854775807",
2001        "-9223372036854775808",
2002        "18446744073709551616",
2003        "1e309",
2004        ";",
2005        "{}",
2006        "()",
2007        "((((",
2008        "))))",
2009        "\"",
2010        "'",
2011        "\\",
2012        "//",
2013        "/*",
2014        "valid;garbage",
2015        "  valid  ",
2016        "a=b=c",
2017        ":::",
2018        "--",
2019    ];
2020
2021    // =====================================================================
2022    // parsers -> malformed / huge / boundary / unicode
2023    // =====================================================================
2024
2025    // --- pseudo_selector_from_str ----------------------------------------
2026
2027    #[test]
2028    fn pseudo_selector_from_str_valid_minimal() {
2029        assert_eq!(
2030            pseudo_selector_from_str("hover", None),
2031            Ok(CssPathPseudoSelector::Hover)
2032        );
2033        assert_eq!(
2034            pseudo_selector_from_str("first", None),
2035            Ok(CssPathPseudoSelector::First)
2036        );
2037        assert_eq!(
2038            pseudo_selector_from_str("last", None),
2039            Ok(CssPathPseudoSelector::Last)
2040        );
2041        assert_eq!(
2042            pseudo_selector_from_str("active", None),
2043            Ok(CssPathPseudoSelector::Active)
2044        );
2045        assert_eq!(
2046            pseudo_selector_from_str("focus", None),
2047            Ok(CssPathPseudoSelector::Focus)
2048        );
2049        assert_eq!(
2050            pseudo_selector_from_str("dragging", None),
2051            Ok(CssPathPseudoSelector::Dragging)
2052        );
2053        assert_eq!(
2054            pseudo_selector_from_str("drag-over", None),
2055            Ok(CssPathPseudoSelector::DragOver)
2056        );
2057        assert_eq!(
2058            pseudo_selector_from_str("root", None),
2059            Ok(CssPathPseudoSelector::Root)
2060        );
2061    }
2062
2063    #[test]
2064    fn pseudo_selector_from_str_nth_child_needs_a_value() {
2065        assert_eq!(
2066            pseudo_selector_from_str("nth-child", None),
2067            Err(CssPseudoSelectorParseError::EmptyNthChild)
2068        );
2069        assert_eq!(
2070            pseudo_selector_from_str("nth-child", Some("2")),
2071            Ok(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(2)))
2072        );
2073    }
2074
2075    #[test]
2076    fn pseudo_selector_from_str_lang_strips_quotes() {
2077        // Both quote styles are stripped, and the inner value is trimmed.
2078        for v in ["de-DE", "\"de-DE\"", "'de-DE'", "  \"de-DE\"  "] {
2079            assert_eq!(
2080                pseudo_selector_from_str("lang", Some(v)),
2081                Ok(CssPathPseudoSelector::Lang(AzString::from("de-DE".to_string()))),
2082                "lang value {v:?} did not normalise to de-DE"
2083            );
2084        }
2085        // A `:lang` with no value is rejected rather than defaulting to "".
2086        assert!(pseudo_selector_from_str("lang", None).is_err());
2087    }
2088
2089    #[test]
2090    fn pseudo_selector_from_str_empty_and_whitespace_are_rejected() {
2091        assert!(pseudo_selector_from_str("", None).is_err());
2092        assert!(pseudo_selector_from_str("   ", None).is_err());
2093        assert!(pseudo_selector_from_str("\t\n", None).is_err());
2094        // The selector name is matched verbatim, so a padded name is *not* accepted.
2095        assert!(pseudo_selector_from_str(" hover ", None).is_err());
2096    }
2097
2098    #[test]
2099    fn pseudo_selector_from_str_garbage_and_unicode_never_panic() {
2100        for s in HOSTILE {
2101            for v in [None, Some(*s), Some("2"), Some("\u{1F600}")] {
2102                let r = catch(|| pseudo_selector_from_str(s, v));
2103                assert!(
2104                    r.is_ok(),
2105                    "pseudo_selector_from_str({s:?}, {v:?}) panicked: {}",
2106                    r.unwrap_err()
2107                );
2108                // Nothing in HOSTILE is a real pseudo-selector name.
2109                assert!(
2110                    pseudo_selector_from_str(s, v).is_err(),
2111                    "pseudo_selector_from_str({s:?}, {v:?}) unexpectedly succeeded"
2112                );
2113            }
2114        }
2115    }
2116
2117    #[test]
2118    fn pseudo_selector_from_str_extremely_long_input_terminates() {
2119        let long = "z".repeat(200_000);
2120        assert!(pseudo_selector_from_str(&long, None).is_err());
2121        // A huge *value* on a selector that ignores values must also terminate.
2122        assert_eq!(
2123            pseudo_selector_from_str("hover", Some(&long)),
2124            Ok(CssPathPseudoSelector::Hover)
2125        );
2126        // A huge nth-child value is rejected, not parsed into a bogus number.
2127        assert!(pseudo_selector_from_str("nth-child", Some(&long)).is_err());
2128    }
2129
2130    #[test]
2131    fn pseudo_selector_from_str_deeply_nested_value_does_not_stack_overflow() {
2132        let nested = "(".repeat(10_000);
2133        let r = catch(|| pseudo_selector_from_str("nth-child", Some(&nested)).is_err());
2134        assert_eq!(r, Ok(true), "deeply nested nth-child value was not rejected safely");
2135    }
2136
2137    // --- parse_attribute_selector ----------------------------------------
2138
2139    #[test]
2140    fn parse_attribute_selector_valid_minimal() {
2141        let sel = parse_attribute_selector("href").expect("bare attribute name must parse");
2142        assert_eq!(sel.name.as_str(), "href");
2143        assert_eq!(sel.op, AttributeMatchOp::Exists);
2144        assert_eq!(sel.value.clone().into_option(), None);
2145    }
2146
2147    #[test]
2148    fn parse_attribute_selector_all_operators() {
2149        let cases: [(&str, AttributeMatchOp); 6] = [
2150            ("a=b", AttributeMatchOp::Eq),
2151            ("a~=b", AttributeMatchOp::Includes),
2152            ("a|=b", AttributeMatchOp::DashMatch),
2153            ("a^=b", AttributeMatchOp::Prefix),
2154            ("a$=b", AttributeMatchOp::Suffix),
2155            ("a*=b", AttributeMatchOp::Substring),
2156        ];
2157        for (input, expected_op) in cases {
2158            let sel = parse_attribute_selector(input)
2159                .unwrap_or_else(|| panic!("{input:?} should parse"));
2160            assert_eq!(sel.name.as_str(), "a", "wrong name for {input:?}");
2161            assert_eq!(sel.op, expected_op, "wrong op for {input:?}");
2162            assert_eq!(
2163                sel.value.clone().into_option().map(|v| v.as_str().to_string()),
2164                Some("b".to_string()),
2165                "wrong value for {input:?}"
2166            );
2167        }
2168    }
2169
2170    #[test]
2171    fn parse_attribute_selector_quotes_are_stripped_and_unbalanced_rejected() {
2172        for input in ["a=\"b\"", "a='b'", "a=b", "  a  =  \"b\"  "] {
2173            let sel = parse_attribute_selector(input)
2174                .unwrap_or_else(|| panic!("{input:?} should parse"));
2175            assert_eq!(
2176                sel.value.clone().into_option().map(|v| v.as_str().to_string()),
2177                Some("b".to_string()),
2178                "quotes not stripped for {input:?}"
2179            );
2180        }
2181        // Unbalanced quoting is a hard reject, not a silent half-strip.
2182        for input in ["a=\"b", "a=b\"", "a='b", "a=b'", "a=\"b'", "a=\"", "a='"] {
2183            assert!(
2184                parse_attribute_selector(input).is_none(),
2185                "unbalanced quote {input:?} should be rejected"
2186            );
2187        }
2188    }
2189
2190    #[test]
2191    fn parse_attribute_selector_empty_and_malformed_are_rejected() {
2192        for input in ["", "   ", "\t\n", "=", "=b", "  =b", "\"a\"", "'a'"] {
2193            assert!(
2194                parse_attribute_selector(input).is_none(),
2195                "{input:?} should be rejected (empty/quoted name)"
2196            );
2197        }
2198        // Names may not contain whitespace.
2199        assert!(parse_attribute_selector("a b").is_none());
2200        assert!(parse_attribute_selector("a b=c").is_none());
2201    }
2202
2203    #[test]
2204    fn parse_attribute_selector_unicode_name_is_accepted_and_does_not_panic() {
2205        let sel = parse_attribute_selector("data-\u{1F600}")
2206            .expect("a non-ASCII attribute name has no whitespace/quotes, so it is accepted");
2207        assert_eq!(sel.name.as_str(), "data-\u{1F600}");
2208
2209        // Multi-byte values must not be sliced mid-char.
2210        let sel = parse_attribute_selector("lang=\"\u{4E2D}\u{6587}\"").expect("unicode value");
2211        assert_eq!(
2212            sel.value.clone().into_option().map(|v| v.as_str().to_string()),
2213            Some("\u{4E2D}\u{6587}".to_string())
2214        );
2215    }
2216
2217    /// Invariant: whatever comes back, the name is never empty and never contains
2218    /// whitespace or quotes -- those are exactly the cases the parser promises to
2219    /// reject. This holds regardless of how the operator split is implemented.
2220    #[test]
2221    fn parse_attribute_selector_result_invariants_hold_for_hostile_input() {
2222        let long = format!("a={}", "x".repeat(100_000));
2223        let nested = format!("a={}", "[".repeat(10_000));
2224        let mut inputs: Vec<&str> = HOSTILE.to_vec();
2225        inputs.push(&long);
2226        inputs.push(&nested);
2227        inputs.push("title=\"a~=b\"");
2228        inputs.push("a=b=c");
2229        inputs.push("[[[[]]]]");
2230
2231        for input in inputs {
2232            let parsed = match catch(|| parse_attribute_selector(input)) {
2233                Ok(p) => p,
2234                Err(msg) => panic!("parse_attribute_selector({input:?}) panicked: {msg}"),
2235            };
2236            if let Some(sel) = parsed {
2237                let name = sel.name.as_str();
2238                assert!(!name.is_empty(), "empty name accepted for {input:?}");
2239                assert!(
2240                    !name.chars().any(|c| c.is_whitespace() || c == '"' || c == '\''),
2241                    "name {name:?} contains whitespace/quotes for input {input:?}"
2242                );
2243            }
2244        }
2245    }
2246
2247    // --- strip_attribute_quotes (private) --------------------------------
2248
2249    #[test]
2250    fn strip_attribute_quotes_balanced_unquoted_and_unbalanced() {
2251        // Balanced -> stripped.
2252        assert_eq!(strip_attribute_quotes("\"abc\""), Some("abc"));
2253        assert_eq!(strip_attribute_quotes("'abc'"), Some("abc"));
2254        assert_eq!(strip_attribute_quotes("\"\""), Some(""));
2255        assert_eq!(strip_attribute_quotes("''"), Some(""));
2256        // Unquoted -> unchanged.
2257        assert_eq!(strip_attribute_quotes("abc"), Some("abc"));
2258        assert_eq!(strip_attribute_quotes(""), Some(""));
2259        assert_eq!(strip_attribute_quotes("a"), Some("a"));
2260        // Unbalanced -> None.
2261        assert_eq!(strip_attribute_quotes("\"abc"), None);
2262        assert_eq!(strip_attribute_quotes("abc\""), None);
2263        assert_eq!(strip_attribute_quotes("'abc"), None);
2264        assert_eq!(strip_attribute_quotes("abc'"), None);
2265        assert_eq!(strip_attribute_quotes("\"abc'"), None);
2266        assert_eq!(strip_attribute_quotes("\""), None);
2267        assert_eq!(strip_attribute_quotes("'"), None);
2268    }
2269
2270    /// The function slices with raw byte indices (`&s[1..s.len() - 1]`), so a
2271    /// multi-byte first/last char is the interesting boundary case. No byte of a
2272    /// multi-byte UTF-8 sequence can equal `"` (0x22) or `'` (0x27), so the slice
2273    /// must always land on a char boundary.
2274    #[test]
2275    fn strip_attribute_quotes_multibyte_boundaries_never_panic() {
2276        let cases = [
2277            "\u{1F600}",
2278            "\"\u{1F600}\"",
2279            "'\u{4E2D}\u{6587}'",
2280            "\u{4E2D}\u{6587}",
2281            "\"\u{301}\"",
2282            "\u{301}",
2283        ];
2284        for s in cases {
2285            let r = catch(|| strip_attribute_quotes(s));
2286            assert!(r.is_ok(), "strip_attribute_quotes({s:?}) panicked: {}", r.unwrap_err());
2287        }
2288        assert_eq!(strip_attribute_quotes("\"\u{1F600}\""), Some("\u{1F600}"));
2289        assert_eq!(strip_attribute_quotes("\u{1F600}"), Some("\u{1F600}"));
2290    }
2291
2292    #[test]
2293    fn strip_attribute_quotes_extremely_long_input_terminates() {
2294        let long = "x".repeat(500_000);
2295        assert_eq!(strip_attribute_quotes(&long), Some(long.as_str()));
2296        let quoted = format!("\"{long}\"");
2297        assert_eq!(strip_attribute_quotes(&quoted), Some(long.as_str()));
2298    }
2299
2300    // --- parse_nth_child_selector / parse_nth_child_pattern (private) -----
2301
2302    #[test]
2303    fn parse_nth_child_selector_valid_minimal() {
2304        assert_eq!(parse_nth_child_selector("2"), Ok(CssNthChildSelector::Number(2)));
2305        assert_eq!(parse_nth_child_selector("0"), Ok(CssNthChildSelector::Number(0)));
2306        assert_eq!(parse_nth_child_selector("even"), Ok(CssNthChildSelector::Even));
2307        assert_eq!(parse_nth_child_selector("odd"), Ok(CssNthChildSelector::Odd));
2308        assert_eq!(parse_nth_child_selector("  7  "), Ok(CssNthChildSelector::Number(7)));
2309        assert_eq!(
2310            parse_nth_child_selector("2n+3"),
2311            Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
2312                pattern_repeat: 2,
2313                offset: 3
2314            }))
2315        );
2316        assert_eq!(
2317            parse_nth_child_selector("2n"),
2318            Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
2319                pattern_repeat: 2,
2320                offset: 0
2321            }))
2322        );
2323    }
2324
2325    #[test]
2326    fn parse_nth_child_selector_empty_is_empty_nth_child_error() {
2327        assert_eq!(
2328            parse_nth_child_selector(""),
2329            Err(CssPseudoSelectorParseError::EmptyNthChild)
2330        );
2331        assert_eq!(
2332            parse_nth_child_selector("   \t\n "),
2333            Err(CssPseudoSelectorParseError::EmptyNthChild)
2334        );
2335        assert_eq!(
2336            parse_nth_child_pattern(""),
2337            Err(CssPseudoSelectorParseError::EmptyNthChild)
2338        );
2339    }
2340
2341    /// `u32` boundaries: MAX parses, MAX+1 and negatives are rejected via
2342    /// `ParseIntError` rather than wrapping or panicking.
2343    #[test]
2344    fn parse_nth_child_selector_numeric_limits_saturate_into_errors() {
2345        assert_eq!(
2346            parse_nth_child_selector("4294967295"),
2347            Ok(CssNthChildSelector::Number(u32::MAX))
2348        );
2349        for overflow in [
2350            "4294967296",
2351            "18446744073709551616",
2352            "99999999999999999999999999",
2353            "-1",
2354            "-0",
2355        ] {
2356            assert!(
2357                parse_nth_child_selector(overflow).is_err(),
2358                "{overflow:?} must not parse as an nth-child index"
2359            );
2360        }
2361        // Huge digit runs must be rejected, not truncated -- and must terminate.
2362        let huge = "9".repeat(100_000);
2363        assert!(parse_nth_child_selector(&huge).is_err());
2364        let huge_repeat = format!("{}n+1", "9".repeat(100_000));
2365        assert!(parse_nth_child_pattern(&huge_repeat).is_err());
2366    }
2367
2368    #[test]
2369    fn parse_nth_child_selector_float_and_non_finite_strings_are_rejected() {
2370        for v in ["NaN", "inf", "-inf", "1.5", "1e5", "0x2", "+2", " 2 n "] {
2371            let r = catch(|| parse_nth_child_selector(v));
2372            assert!(r.is_ok(), "parse_nth_child_selector({v:?}) panicked: {}", r.unwrap_err());
2373        }
2374        assert!(parse_nth_child_selector("NaN").is_err());
2375        assert!(parse_nth_child_selector("inf").is_err());
2376        assert!(parse_nth_child_selector("1.5").is_err());
2377    }
2378
2379    #[test]
2380    fn parse_nth_child_pattern_malformed_offsets_are_rejected() {
2381        // Trailing "+" with no offset.
2382        assert_eq!(
2383            parse_nth_child_pattern("2n+"),
2384            Err(CssPseudoSelectorParseError::InvalidNthChildPattern("2n+"))
2385        );
2386        assert!(parse_nth_child_pattern("2n+   ").is_err());
2387        assert!(parse_nth_child_pattern("2n+x").is_err());
2388        assert!(parse_nth_child_pattern("xn+1").is_err());
2389        // The `.split('n').next()` / `.split('+').next().unwrap()` pair must never
2390        // panic, no matter what the input looks like.
2391        for s in HOSTILE {
2392            let r = catch(|| parse_nth_child_pattern(s));
2393            assert!(r.is_ok(), "parse_nth_child_pattern({s:?}) panicked: {}", r.unwrap_err());
2394        }
2395    }
2396
2397    #[test]
2398    fn parse_nth_child_selector_unicode_never_panics() {
2399        for v in ["\u{1F600}", "\u{FF12}", "2\u{301}", "e\u{301}ven", "\u{4E2D}n+\u{6587}"] {
2400            let r = catch(|| parse_nth_child_selector(v));
2401            assert!(r.is_ok(), "parse_nth_child_selector({v:?}) panicked: {}", r.unwrap_err());
2402            assert!(
2403                parse_nth_child_selector(v).is_err(),
2404                "{v:?} is not a valid nth-child value"
2405            );
2406        }
2407    }
2408
2409    // --- parse_css_path ---------------------------------------------------
2410
2411    #[test]
2412    fn parse_css_path_valid_minimal() {
2413        // Positive control, mirrors the doc example on `parse_css_path`.
2414        assert_eq!(
2415            parse_css_path("* div #my_id > .class:nth-child(2)"),
2416            Ok(CssPath {
2417                selectors: vec![
2418                    CssPathSelector::Global,
2419                    CssPathSelector::Type(NodeTypeTag::from_str("div").unwrap()),
2420                    CssPathSelector::Children,
2421                    CssPathSelector::Id("my_id".to_string().into()),
2422                    CssPathSelector::DirectChildren,
2423                    CssPathSelector::Class("class".to_string().into()),
2424                    CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
2425                        CssNthChildSelector::Number(2)
2426                    )),
2427                ]
2428                .into()
2429            })
2430        );
2431        assert_eq!(
2432            parse_css_path("div"),
2433            Ok(CssPath {
2434                selectors: vec![CssPathSelector::Type(NodeTypeTag::from_str("div").unwrap())]
2435                    .into()
2436            })
2437        );
2438    }
2439
2440    #[test]
2441    fn parse_css_path_empty_and_whitespace_are_empty_path_errors() {
2442        assert_eq!(parse_css_path(""), Err(CssPathParseError::EmptyPath));
2443        assert_eq!(parse_css_path("   "), Err(CssPathParseError::EmptyPath));
2444        assert_eq!(parse_css_path("\t\r\n "), Err(CssPathParseError::EmptyPath));
2445        // An unknown type tag is now a hard error (Selectors L4: an invalid simple
2446        // selector invalidates the selector) rather than being silently dropped into an
2447        // empty path. See parse_css_path_unknown_type_tag_is_not_silently_dropped.
2448        assert!(matches!(
2449            parse_css_path("definitelynotatag"),
2450            Err(CssPathParseError::NodeTypeTag(_))
2451        ));
2452    }
2453
2454    #[test]
2455    fn parse_css_path_garbage_and_unicode_never_panic() {
2456        let long = "div ".repeat(50_000);
2457        let brackets = "[".repeat(10_000);
2458        let braces = "{".repeat(10_000);
2459        let mut inputs: Vec<&str> = HOSTILE.to_vec();
2460        inputs.push(&long);
2461        inputs.push(&brackets);
2462        inputs.push(&braces);
2463        inputs.push("div;garbage");
2464        inputs.push("div }");
2465        inputs.push(":::::");
2466        inputs.push("\u{1F600} > \u{4E2D}\u{6587}");
2467
2468        for input in inputs {
2469            let r = catch(|| parse_css_path(input));
2470            assert!(r.is_ok(), "parse_css_path({:.40?}) panicked: {}", input, r.unwrap_err());
2471        }
2472    }
2473
2474    #[test]
2475    fn parse_css_path_rejects_block_tokens() {
2476        // `{` / `}` are not path tokens; they must not silently produce a path.
2477        for input in ["div { }", "div {", "}"] {
2478            assert!(
2479                parse_css_path(input).is_err(),
2480                "{input:?} contains block tokens and must not parse as a path"
2481            );
2482        }
2483    }
2484
2485    #[test]
2486    fn parse_css_path_unknown_pseudo_selector_is_an_error() {
2487        assert!(parse_css_path("div:definitelynotapseudo").is_err());
2488        assert!(parse_css_path(".x:nth-child(notanumber)").is_err());
2489    }
2490
2491    /// BUG (red): `parse_css_path` swallows an unknown type selector
2492    /// (`if let Ok(nt) = NodeTypeTag::from_str(..)` with no `else`), so
2493    /// `"div definitelynotatag"` parses as `[Type(Div), Children]` -- a path that
2494    /// ends in a dangling descendant combinator and therefore matches *every*
2495    /// descendant of `div`, silently widening the selector. It should either be
2496    /// rejected (like `new_from_str_inner`, which emits a `SkippedRule` warning)
2497    /// or not leave a trailing combinator behind.
2498    #[test]
2499    fn parse_css_path_unknown_type_tag_is_not_silently_dropped() {
2500        let parsed = parse_css_path("div definitelynotatag");
2501        if let Ok(path) = &parsed {
2502            let selectors = path.selectors.as_slice();
2503            assert!(
2504                !matches!(
2505                    selectors.last(),
2506                    Some(
2507                        CssPathSelector::Children
2508                            | CssPathSelector::DirectChildren
2509                            | CssPathSelector::AdjacentSibling
2510                            | CssPathSelector::GeneralSibling
2511                    )
2512                ),
2513                "BUG: the unknown type tag was dropped, leaving a dangling combinator; \
2514                 `div definitelynotatag` now matches every descendant of div. \
2515                 selectors = {selectors:?}"
2516            );
2517        }
2518    }
2519
2520    // --- parse_media_conditions / parse_media_feature ---------------------
2521
2522    #[test]
2523    fn parse_media_conditions_valid_minimal() {
2524        assert_eq!(
2525            parse_media_conditions("screen"),
2526            vec![DynamicSelector::Media(MediaType::Screen)]
2527        );
2528        assert_eq!(
2529            parse_media_conditions("PRINT"),
2530            vec![DynamicSelector::Media(MediaType::Print)]
2531        );
2532        assert_eq!(
2533            parse_media_conditions("all"),
2534            vec![DynamicSelector::Media(MediaType::All)]
2535        );
2536    }
2537
2538    #[test]
2539    fn parse_media_conditions_parenthesised_and_compound() {
2540        let conds = parse_media_conditions("(min-width: 800px)");
2541        assert_eq!(conds.len(), 1);
2542        match &conds[0] {
2543            DynamicSelector::ViewportWidth(r) => {
2544                assert_eq!(r.min(), Some(800.0));
2545                assert_eq!(r.max(), None);
2546            }
2547            other => panic!("expected ViewportWidth, got {other:?}"),
2548        }
2549
2550        let conds = parse_media_conditions("screen and (max-width: 600px)");
2551        assert_eq!(conds.len(), 2, "compound query should yield both conditions");
2552        assert_eq!(conds[0], DynamicSelector::Media(MediaType::Screen));
2553        match &conds[1] {
2554            DynamicSelector::ViewportWidth(r) => {
2555                assert_eq!(r.min(), None);
2556                assert_eq!(r.max(), Some(600.0));
2557            }
2558            other => panic!("expected ViewportWidth, got {other:?}"),
2559        }
2560    }
2561
2562    #[test]
2563    fn parse_media_conditions_empty_and_garbage_yield_no_conditions() {
2564        for input in ["", "   ", "((((", "))))", "\u{1F600}", "and", "(", ")", "()"] {
2565            let r = catch(|| parse_media_conditions(input));
2566            match r {
2567                Ok(conds) => assert!(
2568                    conds.is_empty(),
2569                    "{input:?} should not produce media conditions, got {conds:?}"
2570                ),
2571                Err(msg) => panic!("parse_media_conditions({input:?}) panicked: {msg}"),
2572            }
2573        }
2574    }
2575
2576    #[test]
2577    fn parse_media_conditions_extremely_long_and_deeply_nested_terminate() {
2578        let nested = format!("({})", "(".repeat(10_000));
2579        let r = catch(|| parse_media_conditions(&nested));
2580        assert!(r.is_ok(), "deeply nested media query panicked: {}", r.unwrap_err());
2581
2582        let long = "screen and ".repeat(20_000);
2583        let r = catch(|| parse_media_conditions(&long));
2584        assert!(r.is_ok(), "very long media query panicked: {}", r.unwrap_err());
2585    }
2586
2587    #[test]
2588    fn parse_media_feature_known_features() {
2589        assert_eq!(
2590            parse_media_feature("orientation: portrait"),
2591            Some(DynamicSelector::Orientation(OrientationType::Portrait))
2592        );
2593        assert_eq!(
2594            parse_media_feature("orientation: LANDSCAPE"),
2595            Some(DynamicSelector::Orientation(OrientationType::Landscape))
2596        );
2597        assert_eq!(
2598            parse_media_feature("prefers-color-scheme: dark"),
2599            Some(DynamicSelector::Theme(ThemeCondition::Dark))
2600        );
2601        assert_eq!(
2602            parse_media_feature("prefers-reduced-motion: reduce"),
2603            Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True))
2604        );
2605        assert_eq!(
2606            parse_media_feature("prefers-contrast: more"),
2607            Some(DynamicSelector::PrefersHighContrast(BoolCondition::True))
2608        );
2609        // Keys are matched case-insensitively.
2610        assert!(parse_media_feature("MIN-WIDTH: 800px").is_some());
2611    }
2612
2613    #[test]
2614    fn parse_media_feature_malformed_returns_none() {
2615        for input in [
2616            "",
2617            "   ",
2618            "nocolon",
2619            "min-width:",
2620            "min-width: ",
2621            "min-width: abc",
2622            ": 800px",
2623            "unknown-feature: 800px",
2624            "orientation: sideways",
2625            "\u{1F600}: \u{1F600}",
2626        ] {
2627            let r = catch(|| parse_media_feature(input));
2628            match r {
2629                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
2630                Err(msg) => panic!("parse_media_feature({input:?}) panicked: {msg}"),
2631            }
2632        }
2633    }
2634
2635    /// BUG (red): `MinMaxRange` uses `f32::NAN` as its "no bound" sentinel, and
2636    /// `parse_px_value` happily parses `"NaN"` (Rust's `f32::from_str` accepts it).
2637    /// So `@media (min-width: NaN)` produces a `ViewportWidth` whose `min()` is
2638    /// `None` -- a viewport constraint that constrains nothing and therefore
2639    /// matches *every* viewport, instead of the media query being rejected.
2640    #[test]
2641    fn parse_media_feature_nan_width_does_not_erase_the_constraint() {
2642        for feature in ["min-width: NaN", "min-width: NaNpx", "max-width: nan"] {
2643            match parse_media_feature(feature) {
2644                None => {} // acceptable: the feature was rejected outright
2645                Some(DynamicSelector::ViewportWidth(r)) => {
2646                    assert!(
2647                        r.min().is_some() || r.max().is_some(),
2648                        "BUG: {feature:?} parsed into a ViewportWidth with no bounds at all \
2649                         (the NaN collided with MinMaxRange's `absent` sentinel), so the \
2650                         media query silently matches every viewport"
2651                    );
2652                }
2653                Some(other) => panic!("unexpected selector for {feature:?}: {other:?}"),
2654            }
2655        }
2656    }
2657
2658    // --- parse_px_value ---------------------------------------------------
2659
2660    #[test]
2661    fn parse_px_value_valid_minimal() {
2662        assert_eq!(parse_px_value("800px"), Some(800.0));
2663        assert_eq!(parse_px_value("800"), Some(800.0));
2664        assert_eq!(parse_px_value("  800px  "), Some(800.0));
2665        assert_eq!(parse_px_value("0"), Some(0.0));
2666        assert_eq!(parse_px_value("1.5px"), Some(1.5));
2667        assert_eq!(parse_px_value("-10px"), Some(-10.0));
2668    }
2669
2670    #[test]
2671    fn parse_px_value_malformed_returns_none() {
2672        for input in ["", "   ", "px", "abc", "8 0 0", "800pxx", "\u{1F600}", "800%", "--"] {
2673            let r = catch(|| parse_px_value(input));
2674            match r {
2675                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
2676                Err(msg) => panic!("parse_px_value({input:?}) panicked: {msg}"),
2677            }
2678        }
2679    }
2680
2681    /// f32 range boundaries: overflow saturates to +/-inf and underflow to zero
2682    /// (that is `f32::from_str`'s documented behaviour) -- neither may panic.
2683    #[test]
2684    fn parse_px_value_overflow_and_underflow_saturate_without_panicking() {
2685        assert_eq!(parse_px_value("-0"), Some(-0.0));
2686        assert_eq!(parse_px_value("1e-50"), Some(0.0));
2687        assert_eq!(parse_px_value("3.4e38px"), Some(3.4e38));
2688
2689        // FIXED: parse_px_value now rejects non-finite results (see
2690        // parse_px_value_rejects_non_finite_values). "1e39" is valid CSS number
2691        // *syntax* but overflows f32 to infinity, and an infinite length is exactly
2692        // the non-finite value that would collide with MinMaxRange's NaN "no bound"
2693        // sentinel — so it is rejected rather than saturated. (Was: Some(inf).)
2694        assert_eq!(parse_px_value("1e39px"), None);
2695
2696        let huge_digits = "9".repeat(100_000);
2697        let r = catch(|| parse_px_value(&huge_digits));
2698        assert!(r.is_ok(), "a 100k-digit number panicked: {}", r.unwrap_err());
2699    }
2700
2701    /// BUG (red): `f32::from_str` accepts `"NaN"`, `"inf"` and `"infinity"`, none of
2702    /// which are valid CSS `<length>` values. Because `MinMaxRange` encodes "no
2703    /// bound" as `NaN`, letting a NaN through silently turns a constraint into a
2704    /// wildcard (see `parse_media_feature_nan_width_does_not_erase_the_constraint`).
2705    /// `parse_px_value` should reject non-finite values at the source.
2706    #[test]
2707    fn parse_px_value_rejects_non_finite_values() {
2708        for input in ["NaN", "nan", "inf", "-inf", "infinity", "NaNpx", "infpx", "-infpx"] {
2709            if let Some(px) = parse_px_value(input) {
2710                assert!(
2711                    px.is_finite(),
2712                    "BUG: parse_px_value({input:?}) returned the non-finite value {px}; \
2713                     a non-finite length is not valid CSS and collides with MinMaxRange's \
2714                     NaN `absent` sentinel"
2715                );
2716            }
2717        }
2718    }
2719
2720    // --- parse_ratio_value ------------------------------------------------
2721
2722    #[test]
2723    fn parse_ratio_value_valid_minimal() {
2724        let r = parse_ratio_value("16/9").expect("16/9 should parse");
2725        assert!((r - (16.0 / 9.0)).abs() < 1e-6, "16/9 parsed as {r}");
2726        let r = parse_ratio_value("1.777").expect("bare float should parse");
2727        assert!((r - 1.777).abs() < 1e-6);
2728        let r = parse_ratio_value("  16 / 9  ").expect("whitespace should be trimmed");
2729        assert!((r - (16.0 / 9.0)).abs() < 1e-6);
2730    }
2731
2732    #[test]
2733    fn parse_ratio_value_division_by_zero_is_rejected() {
2734        // Both +0.0 and -0.0 denominators must be caught by the `den == 0.0` guard.
2735        assert_eq!(parse_ratio_value("1/0"), None);
2736        assert_eq!(parse_ratio_value("1/-0"), None);
2737        assert_eq!(parse_ratio_value("0/0"), None);
2738        assert_eq!(parse_ratio_value("16/0.0"), None);
2739    }
2740
2741    #[test]
2742    fn parse_ratio_value_malformed_returns_none() {
2743        for input in ["", "   ", "/", "16/", "/9", "a/b", "1/2/3", "\u{1F600}", "16:9"] {
2744            let r = catch(|| parse_ratio_value(input));
2745            match r {
2746                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
2747                Err(msg) => panic!("parse_ratio_value({input:?}) panicked: {msg}"),
2748            }
2749        }
2750    }
2751
2752    /// BUG (red): the `den == 0.0` guard catches division by zero but not the
2753    /// non-finite operands that produce NaN anyway -- `inf/inf` and `1/NaN` both
2754    /// yield NaN, which then becomes MinMaxRange's "no bound" sentinel and turns
2755    /// an `aspect-ratio` query into a wildcard.
2756    #[test]
2757    fn parse_ratio_value_never_returns_nan() {
2758        for input in ["NaN", "inf/inf", "NaN/1", "1/NaN", "-inf/inf", "inf/-inf"] {
2759            if let Some(r) = parse_ratio_value(input) {
2760                assert!(
2761                    !r.is_nan(),
2762                    "BUG: parse_ratio_value({input:?}) returned NaN, which MinMaxRange \
2763                     reads back as `no bound` -- the aspect-ratio query silently matches \
2764                     everything"
2765                );
2766            }
2767        }
2768    }
2769
2770    #[test]
2771    fn parse_ratio_value_extremely_long_input_terminates() {
2772        let huge = format!("{}/{}", "9".repeat(50_000), "9".repeat(50_000));
2773        let r = catch(|| parse_ratio_value(&huge));
2774        assert!(r.is_ok(), "huge ratio panicked: {}", r.unwrap_err());
2775    }
2776
2777    // --- parse_container_conditions / parse_container_feature -------------
2778
2779    #[test]
2780    fn parse_container_conditions_valid_minimal() {
2781        // Bare name.
2782        assert_eq!(
2783            parse_container_conditions("sidebar"),
2784            vec![DynamicSelector::ContainerName(AzString::from(
2785                "sidebar".to_string()
2786            ))]
2787        );
2788
2789        // Anonymous query.
2790        let conds = parse_container_conditions("(min-width: 400px)");
2791        assert_eq!(conds.len(), 1);
2792        match &conds[0] {
2793            DynamicSelector::ContainerWidth(r) => {
2794                assert_eq!(r.min(), Some(400.0));
2795                assert_eq!(r.max(), None);
2796            }
2797            other => panic!("expected ContainerWidth, got {other:?}"),
2798        }
2799
2800        // Named query -> name + condition.
2801        let conds = parse_container_conditions("sidebar (min-width: 400px)");
2802        assert_eq!(conds.len(), 2);
2803        assert_eq!(
2804            conds[0],
2805            DynamicSelector::ContainerName(AzString::from("sidebar".to_string()))
2806        );
2807        assert!(matches!(conds[1], DynamicSelector::ContainerWidth(_)));
2808    }
2809
2810    #[test]
2811    fn parse_container_conditions_empty_and_garbage_never_panic() {
2812        assert!(parse_container_conditions("").is_empty());
2813        assert!(parse_container_conditions("   ").is_empty());
2814
2815        let nested = "(".repeat(10_000);
2816        let long = "a".repeat(200_000);
2817        let mut inputs: Vec<&str> = HOSTILE.to_vec();
2818        inputs.push(&nested);
2819        inputs.push(&long);
2820
2821        for input in inputs {
2822            let r = catch(|| parse_container_conditions(input));
2823            assert!(
2824                r.is_ok(),
2825                "parse_container_conditions({:.40?}) panicked: {}",
2826                input,
2827                r.unwrap_err()
2828            );
2829        }
2830    }
2831
2832    #[test]
2833    fn parse_container_feature_malformed_returns_none() {
2834        for input in ["", "   ", "nocolon", "min-width:", "min-width: abc", "unknown: 1px"] {
2835            let r = catch(|| parse_container_feature(input));
2836            match r {
2837                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
2838                Err(msg) => panic!("parse_container_feature({input:?}) panicked: {msg}"),
2839            }
2840        }
2841        assert!(parse_container_feature("min-height: 400px").is_some());
2842        assert!(parse_container_feature("MAX-WIDTH: 400px").is_some());
2843    }
2844
2845    // --- parse_theme_condition / parse_lang_condition ---------------------
2846
2847    #[test]
2848    fn parse_theme_condition_valid_minimal() {
2849        for input in ["dark", "(dark)", "DARK", "\"dark\"", "'dark'", "(\"dark\")", "  dark  "] {
2850            assert_eq!(
2851                parse_theme_condition(input),
2852                Some(DynamicSelector::Theme(ThemeCondition::Dark)),
2853                "theme {input:?} should resolve to Dark"
2854            );
2855        }
2856        assert_eq!(
2857            parse_theme_condition("light"),
2858            Some(DynamicSelector::Theme(ThemeCondition::Light))
2859        );
2860    }
2861
2862    #[test]
2863    fn parse_theme_condition_garbage_returns_none() {
2864        for input in ["", "   ", "(", ")", "()", "sepia", "\u{1F600}", "dark light", "\"dark"] {
2865            let r = catch(|| parse_theme_condition(input));
2866            match r {
2867                Ok(v) => assert!(v.is_none(), "theme {input:?} should be rejected, got {v:?}"),
2868                Err(msg) => panic!("parse_theme_condition({input:?}) panicked: {msg}"),
2869            }
2870        }
2871    }
2872
2873    #[test]
2874    fn parse_lang_condition_valid_minimal() {
2875        for input in ["de-DE", "(de-DE)", "(\"de-DE\")", "('de-DE')", "  de-DE  "] {
2876            assert_eq!(
2877                parse_lang_condition(input),
2878                Some(DynamicSelector::Language(LanguageCondition::Prefix(
2879                    AzString::from("de-DE".to_string())
2880                ))),
2881                "lang {input:?} should resolve to Prefix(de-DE)"
2882            );
2883        }
2884    }
2885
2886    #[test]
2887    fn parse_lang_condition_empty_returns_none() {
2888        for input in ["", "   ", "()", "(  )", "( )"] {
2889            assert_eq!(
2890                parse_lang_condition(input),
2891                None,
2892                "empty lang {input:?} must not produce a condition"
2893            );
2894        }
2895    }
2896
2897    #[test]
2898    fn parse_lang_condition_unicode_and_long_input_never_panic() {
2899        let long = "a".repeat(200_000);
2900        let mut inputs: Vec<&str> = HOSTILE.to_vec();
2901        inputs.push(&long);
2902        for input in inputs {
2903            let r = catch(|| parse_lang_condition(input));
2904            assert!(
2905                r.is_ok(),
2906                "parse_lang_condition({:.40?}) panicked: {}",
2907                input,
2908                r.unwrap_err()
2909            );
2910        }
2911    }
2912
2913    // --- css variables ----------------------------------------------------
2914
2915    #[test]
2916    fn parse_css_variable_brace_contents_valid_minimal() {
2917        assert_eq!(
2918            parse_css_variable_brace_contents("--main-bg-col"),
2919            Some(("main-bg-col", None))
2920        );
2921        let (name, default) = parse_css_variable_brace_contents("--main-bg-col, blue")
2922            .expect("var with default should parse");
2923        assert_eq!(name, "main-bg-col");
2924        // NOTE: the default is returned *untrimmed* (" blue"); `parse_css_property`
2925        // trims it later, so assert on the trimmed form to stay fix-stable.
2926        assert_eq!(default.map(str::trim), Some("blue"));
2927    }
2928
2929    #[test]
2930    fn parse_css_variable_brace_contents_rejects_non_variables() {
2931        for input in ["", "   ", "main-bg-col", "-main-bg-col", "blue", "\u{1F600}", ","] {
2932            assert_eq!(
2933                parse_css_variable_brace_contents(input),
2934                None,
2935                "{input:?} is not a `--` prefixed CSS variable"
2936            );
2937        }
2938    }
2939
2940    /// The function slices `&var_name[2..]` after a `starts_with("--")` check.
2941    /// `--` is ASCII, so byte 2 is always a char boundary even when the variable
2942    /// name itself is multi-byte.
2943    #[test]
2944    fn parse_css_variable_brace_contents_multibyte_name_does_not_split_a_char() {
2945        assert_eq!(
2946            parse_css_variable_brace_contents("--\u{1F600}"),
2947            Some(("\u{1F600}", None))
2948        );
2949        // An empty name after `--` is currently accepted; assert only that it is safe.
2950        let r = catch(|| parse_css_variable_brace_contents("--"));
2951        assert!(r.is_ok(), "`--` panicked: {}", r.unwrap_err());
2952    }
2953
2954    #[test]
2955    fn check_if_value_is_css_var_recognises_var_syntax() {
2956        // Not a var() at all.
2957        assert!(check_if_value_is_css_var("100px").is_none());
2958        assert!(check_if_value_is_css_var("").is_none());
2959        assert!(check_if_value_is_css_var("calc(1px + 2px)").is_none());
2960
2961        // A var() without a default falls back to "none".
2962        match check_if_value_is_css_var("var(--main-bg-color)") {
2963            Some(Ok((id, default))) => {
2964                assert_eq!(id, "main-bg-color");
2965                assert_eq!(default, "none");
2966            }
2967            other => panic!("expected Some(Ok(..)), got {other:?}"),
2968        }
2969
2970        // A var() with a default returns it.
2971        match check_if_value_is_css_var("var(--w, 100px)") {
2972            Some(Ok((id, default))) => {
2973                assert_eq!(id, "w");
2974                assert_eq!(default.trim(), "100px");
2975            }
2976            other => panic!("expected Some(Ok(..)), got {other:?}"),
2977        }
2978
2979        // Malformed brace contents surface as an error, not a panic and not a None.
2980        assert!(matches!(
2981            check_if_value_is_css_var("var(nonsense)"),
2982            Some(Err(CssParseErrorInner::DynamicCssParseError(
2983                DynamicCssParseError::InvalidBraceContents(_)
2984            )))
2985        ));
2986        assert!(matches!(
2987            check_if_value_is_css_var("var()"),
2988            Some(Err(CssParseErrorInner::DynamicCssParseError(
2989                DynamicCssParseError::InvalidBraceContents(_)
2990            )))
2991        ));
2992    }
2993
2994    #[test]
2995    fn check_if_value_is_css_var_hostile_input_never_panics() {
2996        let long = format!("var(--{})", "x".repeat(100_000));
2997        let nested = format!("var({})", "(".repeat(10_000));
2998        let mut inputs: Vec<&str> = HOSTILE.to_vec();
2999        inputs.push(&long);
3000        inputs.push(&nested);
3001        inputs.push("var(");
3002        inputs.push("var)");
3003        inputs.push("var((--x))");
3004
3005        for input in inputs {
3006            let r = catch(|| check_if_value_is_css_var(input).is_some());
3007            assert!(
3008                r.is_ok(),
3009                "check_if_value_is_css_var({:.40?}) panicked: {}",
3010                input,
3011                r.unwrap_err()
3012            );
3013        }
3014    }
3015
3016    // --- parse_declaration_resilient / parse_css_declaration --------------
3017
3018    #[test]
3019    fn parse_css_declaration_valid_minimal() {
3020        let km = key_map();
3021        let mut warnings = Vec::new();
3022        let mut declarations = Vec::new();
3023        let r = parse_css_declaration(
3024            "width",
3025            "100px",
3026            loc(0, 0),
3027            &km,
3028            &mut warnings,
3029            &mut declarations,
3030        );
3031        assert_eq!(r, Ok(()));
3032        assert_eq!(declarations.len(), 1);
3033        assert!(matches!(declarations[0], CssDeclaration::Static(_)));
3034        assert!(warnings.is_empty());
3035    }
3036
3037    #[test]
3038    fn parse_css_declaration_unknown_key_is_downgraded_to_a_warning() {
3039        let km = key_map();
3040        let mut warnings = Vec::new();
3041        let mut declarations = Vec::new();
3042        // Documented contract: an unknown key is a warning, not a hard error, so the
3043        // caller can keep processing the rest of the block.
3044        let r = parse_css_declaration(
3045            "definitely-not-a-property",
3046            "1",
3047            loc(0, 0),
3048            &km,
3049            &mut warnings,
3050            &mut declarations,
3051        );
3052        assert_eq!(r, Ok(()));
3053        assert!(declarations.is_empty());
3054        assert_eq!(warnings.len(), 1);
3055        assert!(matches!(
3056            warnings[0].warning,
3057            CssParseWarnMsgInner::UnsupportedKeyValuePair { .. }
3058        ));
3059    }
3060
3061    #[test]
3062    fn parse_css_declaration_bad_value_is_a_hard_error() {
3063        let km = key_map();
3064        let mut warnings = Vec::new();
3065        let mut declarations = Vec::new();
3066        let r = parse_css_declaration(
3067            "width",
3068            "definitely-not-a-length",
3069            loc(0, 0),
3070            &km,
3071            &mut warnings,
3072            &mut declarations,
3073        );
3074        assert!(r.is_err(), "a known key with an unparseable value must error");
3075        assert!(declarations.is_empty());
3076    }
3077
3078    #[test]
3079    fn parse_declaration_resilient_var_on_shorthand_is_rejected() {
3080        let km = key_map();
3081        // `margin` is a shorthand; `var()` on it is ambiguous and must be refused.
3082        let r = parse_declaration_resilient("margin", "var(--m)", loc(0, 0), &km);
3083        assert!(
3084            matches!(r, Err(CssParseErrorInner::VarOnShorthandProperty { .. })),
3085            "expected VarOnShorthandProperty, got {r:?}"
3086        );
3087    }
3088
3089    #[test]
3090    fn parse_declaration_resilient_var_on_normal_property_becomes_dynamic() {
3091        let km = key_map();
3092        let decls = parse_declaration_resilient("width", "var(--w, 100px)", loc(0, 0), &km)
3093            .expect("var() on a non-shorthand property should parse");
3094        assert_eq!(decls.len(), 1);
3095        match &decls[0] {
3096            CssDeclaration::Dynamic(DynamicCssProperty { dynamic_id, .. }) => {
3097                assert_eq!(dynamic_id.as_str(), "w");
3098            }
3099            other => panic!("expected a Dynamic declaration, got {other:?}"),
3100        }
3101    }
3102
3103    #[test]
3104    fn parse_declaration_resilient_hostile_key_value_pairs_never_panic() {
3105        let km = key_map();
3106        let long = "x".repeat(100_000);
3107        let mut inputs: Vec<&str> = HOSTILE.to_vec();
3108        inputs.push(&long);
3109
3110        for key in &inputs {
3111            for value in &inputs {
3112                let r = catch(|| parse_declaration_resilient(key, value, loc(0, 0), &km).is_ok());
3113                assert!(
3114                    r.is_ok(),
3115                    "parse_declaration_resilient({:.30?}, {:.30?}) panicked: {}",
3116                    key,
3117                    value,
3118                    r.unwrap_err()
3119                );
3120            }
3121        }
3122    }
3123
3124    #[test]
3125    fn parse_declaration_resilient_empty_key_is_an_unknown_property() {
3126        let km = key_map();
3127        assert!(matches!(
3128            parse_declaration_resilient("", "", loc(0, 0), &km),
3129            Err(CssParseErrorInner::UnknownPropertyKey("", ""))
3130        ));
3131    }
3132
3133    // =====================================================================
3134    // numeric -> overflow / NaN / saturation / limits
3135    // =====================================================================
3136
3137    #[test]
3138    fn get_line_column_from_error_representative_values() {
3139        let css = "div {\n    width: 100px;\n}";
3140        // Position 0 and 1 both clamp to offset 0 via `saturating_sub(1)`.
3141        assert_eq!(
3142            ErrorLocation { original_pos: 0 }.get_line_column_from_error(css),
3143            (0, 0)
3144        );
3145        let (line, _col) = ErrorLocation { original_pos: 12 }.get_line_column_from_error(css);
3146        assert_eq!(line, 2, "byte 11 is on the second line");
3147    }
3148
3149    #[test]
3150    fn get_line_column_from_error_empty_css_does_not_panic() {
3151        let r = catch(|| ErrorLocation { original_pos: 0 }.get_line_column_from_error(""));
3152        assert_eq!(r, Ok((0, 0)));
3153    }
3154
3155    /// The column arithmetic (`error_location - total_characters.saturating_sub(2)`)
3156    /// is an unchecked subtraction; newline-heavy inputs are the worst case for it.
3157    #[test]
3158    fn get_line_column_from_error_newline_heavy_input_does_not_underflow() {
3159        let newlines = "\n".repeat(1_000);
3160        let crlf = "\r\n".repeat(1_000);
3161        for css in [newlines.as_str(), crlf.as_str()] {
3162            for pos in [1_usize, 2, 3, 500, css.len()] {
3163                let r = catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css));
3164                assert!(
3165                    r.is_ok(),
3166                    "get_line_column_from_error(pos={pos}) underflowed/panicked: {}",
3167                    r.unwrap_err()
3168                );
3169            }
3170        }
3171    }
3172
3173    /// BUG (red): `css_string[0..error_location]` is an unchecked slice. An
3174    /// `original_pos` past the end of the string -- trivially reachable, since
3175    /// `ErrorLocation` is a `pub` struct with a `pub` field and the method takes an
3176    /// arbitrary `&str` -- panics with "byte index out of bounds" instead of
3177    /// clamping.
3178    #[test]
3179    fn get_line_column_from_error_out_of_range_pos_does_not_panic() {
3180        let css = "div {}";
3181        for pos in [css.len() + 2, 999, usize::MAX] {
3182            if let Err(msg) =
3183                catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css))
3184            {
3185                panic!(
3186                    "BUG: get_line_column_from_error panicked for original_pos={pos} on a \
3187                     {}-byte string (unchecked `css_string[0..error_location]` slice); it \
3188                     should clamp instead: {msg}",
3189                    css.len()
3190                );
3191            }
3192        }
3193    }
3194
3195    /// BUG (red): the same unchecked slice also ignores UTF-8 char boundaries.
3196    /// `original_pos = css.len()` is exactly what `get_error_location` records at
3197    /// `Token::EndOfStream`, so a stylesheet whose last character is multi-byte
3198    /// makes `original_pos - 1` land *inside* that character and the slice panics
3199    /// with "byte index is not a char boundary".
3200    #[test]
3201    fn get_line_column_from_error_at_end_of_unicode_css_does_not_panic() {
3202        // "a\u{1F600}" is 5 bytes; the only char boundaries are 0, 1 and 5.
3203        let css = "a\u{1F600}";
3204        assert_eq!(css.len(), 5);
3205        let pos = css.len(); // -> error_location == 4, which is mid-emoji
3206        if let Err(msg) =
3207            catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css))
3208        {
3209            panic!(
3210                "BUG: get_line_column_from_error panicked at end-of-stream (original_pos={pos}) \
3211                 because the CSS ends with a multi-byte char and `original_pos - 1` splits it: \
3212                 {msg}"
3213            );
3214        }
3215    }
3216
3217    // =====================================================================
3218    // getters / predicates -> invariants
3219    // =====================================================================
3220
3221    #[test]
3222    fn get_error_string_returns_the_trimmed_slice_between_start_and_end() {
3223        let css = "div { width: 100px; }";
3224        let err = CssParseError {
3225            css_string: css,
3226            error: CssParseErrorInner::MalformedCss,
3227            location: loc(6, 18),
3228        };
3229        assert_eq!(err.get_error_string(), "width: 100px");
3230
3231        // An empty range yields an empty string rather than panicking.
3232        let err = CssParseError {
3233            css_string: css,
3234            error: CssParseErrorInner::MalformedCss,
3235            location: loc(0, 0),
3236        };
3237        assert_eq!(err.get_error_string(), "");
3238    }
3239
3240    /// BUG (red): `get_error_string` slices `&self.css_string[start..end]` with no
3241    /// validation. A location that is out of range, reversed, or lands inside a
3242    /// multi-byte char panics. `CssParseError` is `pub` with `pub` fields (and is
3243    /// rebuilt from an owned value by `CssParseErrorOwned::to_shared`, where nothing
3244    /// re-checks the location against the string), so this is reachable.
3245    #[test]
3246    fn get_error_string_invalid_location_does_not_panic() {
3247        let cases: [(&str, ErrorLocationRange, &str); 3] = [
3248            ("div", loc(0, 99), "end past the end of the string"),
3249            ("div", loc(2, 1), "reversed range (start > end)"),
3250            ("a\u{1F600}", loc(0, 4), "end inside a multi-byte char"),
3251        ];
3252        for (css, location, why) in cases {
3253            let err = CssParseError {
3254                css_string: css,
3255                error: CssParseErrorInner::MalformedCss,
3256                location,
3257            };
3258            if let Err(msg) = catch(|| err.get_error_string().to_string()) {
3259                panic!(
3260                    "BUG: get_error_string panicked on an invalid location ({why}) instead of \
3261                     returning an empty/clamped slice: {msg}"
3262                );
3263            }
3264        }
3265    }
3266
3267    // --- serializer: Display for CssParseError ----------------------------
3268
3269    #[test]
3270    fn display_of_css_parse_error_is_non_empty_and_well_formed() {
3271        let css = "div { width: 100px; }";
3272        let err = CssParseError {
3273            css_string: css,
3274            error: CssParseErrorInner::MalformedCss,
3275            location: loc(6, 18),
3276        };
3277        let s = format!("{err}");
3278        assert!(!s.is_empty());
3279        assert!(s.contains("start: line"), "missing start location: {s}");
3280        assert!(s.contains("end: line"), "missing end location: {s}");
3281        assert!(s.contains("width: 100px"), "missing offending text: {s}");
3282        assert!(s.contains("Malformed Css"), "missing reason: {s}");
3283    }
3284
3285    #[test]
3286    fn display_of_css_parse_error_on_zero_value_does_not_panic() {
3287        let err = CssParseError {
3288            css_string: "",
3289            error: CssParseErrorInner::UnclosedBlock,
3290            location: ErrorLocationRange::default(),
3291        };
3292        let r = catch(|| format!("{err}"));
3293        match r {
3294            Ok(s) => assert!(!s.is_empty(), "Display produced an empty string"),
3295            Err(msg) => panic!("Display panicked on a zero-valued CssParseError: {msg}"),
3296        }
3297    }
3298
3299    /// BUG (red): `Display for CssParseError` calls both `get_line_column_from_error`
3300    /// and `get_error_string`, so it inherits their unchecked slicing. Formatting the
3301    /// error for a stylesheet that ends in a multi-byte character panics -- i.e. the
3302    /// *error reporting path* itself crashes on non-ASCII CSS.
3303    #[test]
3304    fn display_of_css_parse_error_with_unicode_css_does_not_panic() {
3305        let css = "p{}\u{1F600}"; // 7 bytes; boundaries at 0..=3 and 7
3306        let err = CssParseError {
3307            css_string: css,
3308            error: CssParseErrorInner::MalformedCss,
3309            location: loc(0, css.len()),
3310        };
3311        if let Err(msg) = catch(|| format!("{err}")) {
3312            panic!(
3313                "BUG: Display for CssParseError panicked while formatting an error whose CSS \
3314                 ends in a multi-byte char (unchecked slicing in get_line_column_from_error / \
3315                 get_error_string): {msg}"
3316            );
3317        }
3318    }
3319
3320    #[test]
3321    fn display_of_error_and_warning_inners_is_never_empty() {
3322        let km = key_map();
3323        let margin = CombinedCssPropertyType::from_str("margin", &km).expect("margin is a shorthand");
3324
3325        let inners: Vec<CssParseErrorInner<'_>> = vec![
3326            CssParseErrorInner::ParseError(CssSyntaxError::UnknownToken(CssSyntaxErrorPos {
3327                row: usize::MAX,
3328                col: usize::MAX,
3329            })),
3330            CssParseErrorInner::UnclosedBlock,
3331            CssParseErrorInner::MalformedCss,
3332            CssParseErrorInner::DynamicCssParseError(DynamicCssParseError::InvalidBraceContents(
3333                "",
3334            )),
3335            CssParseErrorInner::PseudoSelectorParseError(
3336                CssPseudoSelectorParseError::EmptyNthChild,
3337            ),
3338            CssParseErrorInner::NodeTypeTag(NodeTypeTagParseError::Invalid("")),
3339            CssParseErrorInner::UnknownPropertyKey("", ""),
3340            CssParseErrorInner::VarOnShorthandProperty {
3341                key: margin,
3342                value: "",
3343            },
3344        ];
3345
3346        for inner in &inners {
3347            let s = format!("{inner}");
3348            assert!(!s.is_empty(), "empty Display for {inner:?}");
3349        }
3350
3351        let warnings = vec![
3352            CssParseWarnMsgInner::UnsupportedKeyValuePair { key: "", value: "" },
3353            CssParseWarnMsgInner::ParseError(CssParseErrorInner::MalformedCss),
3354            CssParseWarnMsgInner::SkippedRule {
3355                selector: None,
3356                error: CssParseErrorInner::UnclosedBlock,
3357            },
3358            CssParseWarnMsgInner::SkippedDeclaration {
3359                key: "",
3360                value: "",
3361                error: CssParseErrorInner::MalformedCss,
3362            },
3363            CssParseWarnMsgInner::MalformedStructure { message: "" },
3364        ];
3365        for w in &warnings {
3366            assert!(!format!("{w}").is_empty(), "empty Display for {w:?}");
3367        }
3368    }
3369
3370    // =====================================================================
3371    // round-trip -> to_contained() == to_shared()
3372    // =====================================================================
3373
3374    #[test]
3375    fn css_pseudo_selector_parse_error_round_trips() {
3376        let cases = vec![
3377            CssPseudoSelectorParseError::EmptyNthChild,
3378            CssPseudoSelectorParseError::UnknownSelector("blah", None),
3379            CssPseudoSelectorParseError::UnknownSelector("blah", Some("3")),
3380            CssPseudoSelectorParseError::InvalidNthChildPattern("2x+1"),
3381            CssPseudoSelectorParseError::InvalidNthChild(
3382                "x".parse::<u32>().unwrap_err(),
3383            ),
3384            CssPseudoSelectorParseError::InvalidNthChild(
3385                "99999999999999999999".parse::<u32>().unwrap_err(),
3386            ),
3387            // Empty / extreme payloads.
3388            CssPseudoSelectorParseError::UnknownSelector("", Some("")),
3389        ];
3390        for case in &cases {
3391            assert_eq!(
3392                &case.to_contained().to_shared(),
3393                case,
3394                "round-trip changed the value"
3395            );
3396        }
3397    }
3398
3399    #[test]
3400    fn dynamic_css_parse_error_round_trips() {
3401        let simple = DynamicCssParseError::InvalidBraceContents("--x, blue");
3402        assert_eq!(&simple.to_contained().to_shared(), &simple);
3403
3404        let empty = DynamicCssParseError::InvalidBraceContents("");
3405        assert_eq!(&empty.to_contained().to_shared(), &empty);
3406
3407        // A real `CssParsingError` from the property parser. The nested error has its
3408        // own owned/shared pair, so compare via `Display` (semantics) plus the variant.
3409        let km = key_map();
3410        let width = CssPropertyType::from_str("width", &km).expect("width is a property");
3411        let inner = parse_css_property(width, "definitely-not-a-length")
3412            .expect_err("an invalid length must fail to parse");
3413        let wrapped = DynamicCssParseError::UnexpectedValue(inner);
3414        let round_tripped = wrapped.to_contained();
3415        let back = round_tripped.to_shared();
3416        assert!(matches!(back, DynamicCssParseError::UnexpectedValue(_)));
3417        assert_eq!(
3418            format!("{back}"),
3419            format!("{wrapped}"),
3420            "round-trip lost information from the nested CssParsingError"
3421        );
3422    }
3423
3424    #[test]
3425    fn css_parse_error_inner_round_trips_for_every_variant() {
3426        let km = key_map();
3427        let margin =
3428            CombinedCssPropertyType::from_str("margin", &km).expect("margin is a shorthand");
3429
3430        let cases = vec![
3431            CssParseErrorInner::ParseError(CssSyntaxError::UnexpectedEndOfStream(
3432                CssSyntaxErrorPos { row: 0, col: 0 },
3433            )),
3434            // Extreme numeric payloads must survive the FFI hop unchanged.
3435            CssParseErrorInner::ParseError(CssSyntaxError::InvalidAdvance(
3436                CssSyntaxInvalidAdvance {
3437                    expected: isize::MIN,
3438                    total: usize::MAX,
3439                    pos: CssSyntaxErrorPos {
3440                        row: usize::MAX,
3441                        col: usize::MAX,
3442                    },
3443                },
3444            )),
3445            CssParseErrorInner::ParseError(CssSyntaxError::UnsupportedToken(CssSyntaxErrorPos {
3446                row: 3,
3447                col: 7,
3448            })),
3449            CssParseErrorInner::UnclosedBlock,
3450            CssParseErrorInner::MalformedCss,
3451            CssParseErrorInner::DynamicCssParseError(DynamicCssParseError::InvalidBraceContents(
3452                "--x",
3453            )),
3454            CssParseErrorInner::PseudoSelectorParseError(
3455                CssPseudoSelectorParseError::EmptyNthChild,
3456            ),
3457            CssParseErrorInner::NodeTypeTag(NodeTypeTagParseError::Invalid("notatag")),
3458            CssParseErrorInner::UnknownPropertyKey("key", "value"),
3459            CssParseErrorInner::UnknownPropertyKey("", ""),
3460            CssParseErrorInner::VarOnShorthandProperty {
3461                key: margin,
3462                value: "var(--m)",
3463            },
3464        ];
3465
3466        for case in &cases {
3467            assert_eq!(
3468                &case.to_contained().to_shared(),
3469                case,
3470                "round-trip changed the value for {case:?}"
3471            );
3472        }
3473    }
3474
3475    #[test]
3476    fn css_parse_error_round_trips_including_unicode_payloads() {
3477        let css = "div { width: \u{1F600}; }";
3478        let err = CssParseError {
3479            css_string: css,
3480            error: CssParseErrorInner::UnknownPropertyKey("k\u{1F600}", "v\u{4E2D}"),
3481            location: loc(1, 2),
3482        };
3483        assert_eq!(err.to_contained().to_shared(), err);
3484
3485        // Zero value.
3486        let err = CssParseError {
3487            css_string: "",
3488            error: CssParseErrorInner::MalformedCss,
3489            location: ErrorLocationRange::default(),
3490        };
3491        assert_eq!(err.to_contained().to_shared(), err);
3492    }
3493
3494    #[test]
3495    fn css_path_parse_error_round_trips_for_every_variant() {
3496        let cases = vec![
3497            CssPathParseError::EmptyPath,
3498            CssPathParseError::InvalidTokenEncountered("{"),
3499            CssPathParseError::InvalidTokenEncountered(""),
3500            CssPathParseError::UnexpectedEndOfStream("div"),
3501            CssPathParseError::SyntaxError(CssSyntaxError::UnknownToken(CssSyntaxErrorPos {
3502                row: usize::MAX,
3503                col: 0,
3504            })),
3505            CssPathParseError::NodeTypeTag(NodeTypeTagParseError::Invalid("notatag")),
3506            CssPathParseError::PseudoSelectorParseError(
3507                CssPseudoSelectorParseError::InvalidNthChildPattern("2x"),
3508            ),
3509        ];
3510        for case in &cases {
3511            assert_eq!(
3512                &case.to_contained().to_shared(),
3513                case,
3514                "round-trip changed the value for {case:?}"
3515            );
3516        }
3517    }
3518
3519    #[test]
3520    fn css_parse_warn_msg_round_trips_for_every_variant() {
3521        let inners = vec![
3522            CssParseWarnMsgInner::UnsupportedKeyValuePair {
3523                key: "foo",
3524                value: "bar",
3525            },
3526            CssParseWarnMsgInner::UnsupportedKeyValuePair { key: "", value: "" },
3527            CssParseWarnMsgInner::ParseError(CssParseErrorInner::MalformedCss),
3528            CssParseWarnMsgInner::SkippedRule {
3529                selector: None,
3530                error: CssParseErrorInner::UnclosedBlock,
3531            },
3532            CssParseWarnMsgInner::SkippedRule {
3533                selector: Some("div"),
3534                error: CssParseErrorInner::MalformedCss,
3535            },
3536            CssParseWarnMsgInner::SkippedDeclaration {
3537                key: "width",
3538                value: "\u{1F600}",
3539                error: CssParseErrorInner::MalformedCss,
3540            },
3541            CssParseWarnMsgInner::MalformedStructure {
3542                message: "unclosed",
3543            },
3544        ];
3545
3546        for inner in &inners {
3547            assert_eq!(
3548                &inner.to_contained().to_shared(),
3549                inner,
3550                "round-trip changed the value for {inner:?}"
3551            );
3552
3553            let msg = CssParseWarnMsg {
3554                warning: inner.clone(),
3555                location: loc(7, 42),
3556            };
3557            let back = msg.to_contained();
3558            let back = back.to_shared();
3559            assert_eq!(back, msg, "CssParseWarnMsg round-trip changed the value");
3560            assert_eq!(back.location, loc(7, 42), "location was not preserved");
3561        }
3562    }
3563
3564    #[test]
3565    fn unparsed_css_rule_block_round_trips() {
3566        let mut declarations = BTreeMap::new();
3567        declarations.insert("width", ("100px", loc(1, 2)));
3568        declarations.insert("color", ("\u{1F600}", loc(3, 4)));
3569
3570        let block = UnparsedCssRuleBlock {
3571            path: CssPath {
3572                selectors: vec![
3573                    CssPathSelector::Global,
3574                    CssPathSelector::Class("btn".to_string().into()),
3575                ]
3576                .into(),
3577            },
3578            declarations,
3579            // NB: deliberately no MinMaxRange condition here -- those store `f32::NAN`
3580            // as the "absent bound" sentinel, so they are not equal to themselves under
3581            // the derived `PartialEq` (see dynamic_selector.rs).
3582            conditions: vec![DynamicSelector::Media(MediaType::Screen)],
3583        };
3584
3585        assert_eq!(block.to_contained().to_shared(), block);
3586
3587        // Empty instance.
3588        let empty = UnparsedCssRuleBlock {
3589            path: CssPath {
3590                selectors: Vec::new().into(),
3591            },
3592            declarations: BTreeMap::new(),
3593            conditions: Vec::new(),
3594        };
3595        assert_eq!(empty.to_contained().to_shared(), empty);
3596    }
3597
3598    // =====================================================================
3599    // other -> new_from_str / new_from_str_inner / css_blocks_to_stylesheet
3600    // =====================================================================
3601
3602    #[test]
3603    fn new_from_str_valid_minimal() {
3604        let (css, warnings) = new_from_str("div { width: 100px; }");
3605        assert_eq!(css.rules.len(), 1);
3606        assert_eq!(css.rules.as_slice()[0].declarations.len(), 1);
3607        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3608    }
3609
3610    #[test]
3611    fn new_from_str_empty_input_yields_an_empty_stylesheet() {
3612        let (css, warnings) = new_from_str("");
3613        assert_eq!(css.rules.len(), 0);
3614        assert!(warnings.is_empty());
3615
3616        let (css, _warnings) = new_from_str("   \n\t  ");
3617        assert_eq!(css.rules.len(), 0);
3618    }
3619
3620    #[test]
3621    fn new_from_str_unclosed_block_warns_instead_of_failing() {
3622        let (css, warnings) = new_from_str("div { width: 100px;");
3623        assert_eq!(css.rules.len(), 0, "an unclosed block emits no rules");
3624        assert!(
3625            warnings.iter().any(|w| matches!(
3626                w.warning,
3627                CssParseWarnMsgInner::MalformedStructure { .. }
3628            )),
3629            "expected a MalformedStructure warning, got {warnings:?}"
3630        );
3631    }
3632
3633    #[test]
3634    fn new_from_str_unknown_property_is_a_warning_not_a_dropped_rule() {
3635        let (css, warnings) = new_from_str("div { definitely-not-a-property: 1; width: 10px; }");
3636        assert_eq!(css.rules.len(), 1);
3637        // The unknown key is skipped but the valid declaration survives.
3638        assert_eq!(css.rules.as_slice()[0].declarations.len(), 1);
3639        assert!(!warnings.is_empty(), "the unknown key should have warned");
3640    }
3641
3642    #[test]
3643    fn var_reference_resolves_against_a_root_custom_property() {
3644        let (css, _) = new_from_str(":root{--boxw:150px} .v{width:var(--boxw)}");
3645        // The `--boxw` DEFINITION emits no declaration; the `var(--boxw)` REFERENCE is
3646        // resolved to a concrete Static value, so exactly one declaration survives overall.
3647        let decls: Vec<_> = css
3648            .rules
3649            .as_slice()
3650            .iter()
3651            .flat_map(|r| r.declarations.as_slice().iter())
3652            .collect();
3653        assert_eq!(decls.len(), 1, "custom-prop def emits nothing, var() resolves: {decls:?}");
3654        // The resolved declaration is identical to a direct `width:150px`.
3655        let (direct, _) = new_from_str(".v{width:150px}");
3656        assert_eq!(decls[0], &direct.rules.as_slice()[0].declarations.as_slice()[0]);
3657    }
3658
3659    #[test]
3660    fn undefined_var_reference_falls_back_to_its_default() {
3661        let (css, _) = new_from_str(".v{width:var(--nope, 42px)}");
3662        let (direct, _) = new_from_str(".v{width:42px}");
3663        assert_eq!(
3664            css.rules.as_slice()[0].declarations.as_slice()[0],
3665            direct.rules.as_slice()[0].declarations.as_slice()[0],
3666        );
3667    }
3668
3669    /// `new_from_str` documents "Never panics" -- hold it to that.
3670    #[test]
3671    fn new_from_str_hostile_input_never_panics() {
3672        let long_rule = "div { width: 100px; }".repeat(2_000);
3673        let deep_nesting = format!("{}{}", "div {".repeat(500), "}".repeat(500));
3674        let unbalanced_open = "{".repeat(5_000);
3675        let unbalanced_close = "}".repeat(5_000);
3676        let long_selector = format!("{} {{ width: 1px; }}", "div ".repeat(10_000));
3677
3678        let mut inputs: Vec<&str> = HOSTILE.to_vec();
3679        inputs.push(&long_rule);
3680        inputs.push(&deep_nesting);
3681        inputs.push(&unbalanced_open);
3682        inputs.push(&unbalanced_close);
3683        inputs.push(&long_selector);
3684        inputs.push("div { width: \u{1F600}; }");
3685        inputs.push("\u{1F600} { \u{4E2D}: \u{6587}; }");
3686        inputs.push("div { width: 100px; /* unterminated");
3687        inputs.push("@media (min-width: 800px) { div { width: 1px; } }");
3688        inputs.push("@theme(dark) { div { width: 1px; } }");
3689        inputs.push("@lang(\"de-DE\") { div { width: 1px; } }");
3690        inputs.push("@container sidebar (min-width: 400px) { div { width: 1px; } }");
3691        inputs.push("@definitely-not-an-at-rule x { div { width: 1px; } }");
3692        inputs.push(".a { .b { :hover { width: 1px; } } }");
3693        inputs.push("div[data-x=\"y\"] { width: 1px; }");
3694        inputs.push("div[ { width: 1px; }");
3695        inputs.push("a, b, , c { width: 1px; }");
3696        inputs.push("div:nth-child(999999999999) { width: 1px; }");
3697
3698        for input in inputs {
3699            let r = catch(|| {
3700                let (css, warnings) = new_from_str(input);
3701                (css.rules.len(), warnings.len())
3702            });
3703            assert!(
3704                r.is_ok(),
3705                "new_from_str({:.60?}) panicked despite the `Never panics` contract: {}",
3706                input,
3707                r.unwrap_err()
3708            );
3709        }
3710    }
3711
3712    #[test]
3713    fn new_from_str_at_rules_attach_conditions_to_nested_rules() {
3714        let (css, _warnings) = new_from_str("@media screen { div { width: 1px; } }");
3715        assert_eq!(css.rules.len(), 1);
3716        let rule = &css.rules.as_slice()[0];
3717        assert!(
3718            rule.conditions
3719                .as_slice()
3720                .contains(&DynamicSelector::Media(MediaType::Screen)),
3721            "the @media condition was not attached: {:?}",
3722            rule.conditions.as_slice()
3723        );
3724    }
3725
3726    #[test]
3727    fn new_from_str_comma_separated_selectors_emit_one_rule_each() {
3728        let (css, _warnings) = new_from_str("div, p { width: 1px; }");
3729        assert_eq!(css.rules.len(), 2, "each selector in the list gets its own rule");
3730    }
3731
3732    #[test]
3733    fn new_from_str_inner_matches_new_from_str() {
3734        let css_string = "div { width: 100px; }";
3735        let mut tokenizer = Tokenizer::new(css_string);
3736        let (rules, warnings) = new_from_str_inner(css_string, &mut tokenizer);
3737        assert_eq!(rules.len(), 1);
3738        assert!(warnings.is_empty());
3739    }
3740
3741    #[test]
3742    fn get_error_location_tracks_the_tokenizer_position() {
3743        let css_string = "div { width: 100px; }";
3744        let mut tokenizer = Tokenizer::new(css_string);
3745        assert_eq!(get_error_location(&tokenizer).original_pos, 0);
3746
3747        let _ = tokenizer.parse_next();
3748        let after = get_error_location(&tokenizer).original_pos;
3749        assert!(after > 0, "the tokenizer position did not advance");
3750        assert!(
3751            after <= css_string.len(),
3752            "the tokenizer position ran past the end of the input"
3753        );
3754
3755        // Position on an empty document is 0 and must not panic.
3756        let empty = Tokenizer::new("");
3757        assert_eq!(get_error_location(&empty).original_pos, 0);
3758    }
3759
3760    #[test]
3761    fn css_blocks_to_stylesheet_parses_known_keys_and_warns_on_unknown_ones() {
3762        let css_string = "div { width: 100px; }";
3763
3764        let mut declarations = BTreeMap::new();
3765        declarations.insert("width", ("100px", loc(6, 18)));
3766        let good = UnparsedCssRuleBlock {
3767            path: CssPath {
3768                selectors: vec![CssPathSelector::Global].into(),
3769            },
3770            declarations,
3771            conditions: Vec::new(),
3772        };
3773
3774        let mut declarations = BTreeMap::new();
3775        declarations.insert("definitely-not-a-property", ("1", loc(0, 1)));
3776        let bad = UnparsedCssRuleBlock {
3777            path: CssPath {
3778                selectors: vec![CssPathSelector::Global].into(),
3779            },
3780            declarations,
3781            conditions: Vec::new(),
3782        };
3783
3784        let (rules, warnings) = css_blocks_to_stylesheet(vec![good, bad], css_string);
3785        assert_eq!(rules.len(), 2, "both blocks are emitted");
3786        assert_eq!(rules[0].declarations.len(), 1);
3787        assert_eq!(rules[1].declarations.len(), 0, "the unknown key is dropped");
3788        assert_eq!(warnings.len(), 1, "the unknown key produced exactly one warning");
3789        assert!(matches!(
3790            warnings[0].warning,
3791            CssParseWarnMsgInner::SkippedDeclaration { .. }
3792        ));
3793    }
3794
3795    #[test]
3796    fn css_blocks_to_stylesheet_empty_input_is_empty_output() {
3797        let (rules, warnings) = css_blocks_to_stylesheet(Vec::new(), "");
3798        assert!(rules.is_empty());
3799        assert!(warnings.is_empty());
3800    }
3801}