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