Skip to main content

azul_css/
parser2.rs

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