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.
13
use alloc::{collections::BTreeMap, string::ToString, vec::Vec};
14
use core::{fmt, num::ParseIntError};
15

            
16
pub use azul_simplecss::Error as SimplecssError;
17
use azul_simplecss::Tokenizer;
18

            
19
/// FFI-safe position of a CSS syntax error.
20
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21
#[repr(C)]
22
pub struct CssSyntaxErrorPos {
23
    pub row: usize,
24
    pub col: usize,
25
}
26

            
27
impl From<azul_simplecss::ErrorPos> for CssSyntaxErrorPos {
28
243
    fn from(p: azul_simplecss::ErrorPos) -> Self {
29
243
        Self { row: p.row, col: p.col }
30
243
    }
31
}
32

            
33
/// FFI-safe wrapper for invalid advance details in CSS syntax errors.
34
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
#[repr(C)]
36
pub 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)]
45
pub enum CssSyntaxError {
46
    UnexpectedEndOfStream(CssSyntaxErrorPos),
47
    InvalidAdvance(CssSyntaxInvalidAdvance),
48
    UnsupportedToken(CssSyntaxErrorPos),
49
    UnknownToken(CssSyntaxErrorPos),
50
}
51

            
52
impl From<SimplecssError> for CssSyntaxError {
53
243
    fn from(e: SimplecssError) -> Self {
54
243
        match e {
55
35
            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
208
            SimplecssError::UnknownToken(pos) => Self::UnknownToken(pos.into()),
59
        }
60
243
    }
61
}
62

            
63
pub use crate::props::property::CssParsingError;
64
use 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)]
86
pub 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)]
95
pub struct CssParseErrorOwned {
96
    pub css_string: AzString,
97
    pub error: CssParseErrorInnerOwned,
98
    pub location: ErrorLocationRange,
99
}
100

            
101
impl CssParseError<'_> {
102
2
    #[must_use] pub fn to_contained(&self) -> CssParseErrorOwned {
103
2
        CssParseErrorOwned {
104
2
            css_string: self.css_string.to_string().into(),
105
2
            error: self.error.to_contained(),
106
2
            location: self.location,
107
2
        }
108
2
    }
109
}
110

            
111
impl CssParseErrorOwned {
112
2
    #[must_use] pub fn to_shared(&self) -> CssParseError<'_> {
113
2
        CssParseError {
114
2
            css_string: self.css_string.as_str(),
115
2
            error: self.error.to_shared(),
116
2
            location: self.location,
117
2
        }
118
2
    }
119
}
120

            
121
/// Clamps a byte offset into `s` so it is in-bounds AND on a UTF-8 char boundary,
122
/// rounding DOWN to the nearest boundary.
123
///
124
/// Error locations are recorded as raw byte offsets and are reachable from public,
125
/// unvalidated fields, so they cannot be fed to a slice directly: an out-of-range or
126
/// mid-character offset panics. Every error-reporting path routes through here.
127
#[must_use]
128
39
fn clamp_to_char_boundary(s: &str, pos: usize) -> usize {
129
39
    let mut pos = pos.min(s.len());
130
48
    while pos > 0 && !s.is_char_boundary(pos) {
131
9
        pos -= 1;
132
9
    }
133
39
    pos
134
39
}
135

            
136
impl<'a> CssParseError<'a> {
137
    /// Returns the string between the (start, end) location
138
8
    #[must_use] pub fn get_error_string(&self) -> &'a str {
139
8
        let (start, end) = (self.location.start.original_pos, self.location.end.original_pos);
140
        // `location` is a pub field on a pub struct and `CssParseErrorOwned::to_shared`
141
        // rebuilds one without revalidating, so start/end are NOT trustworthy: they can
142
        // sit past the end, be reversed, or land inside a multi-byte char. A raw slice
143
        // panics on all three -- while merely *displaying* an error. Clamp instead.
144
8
        let start = clamp_to_char_boundary(self.css_string, start);
145
8
        let end = clamp_to_char_boundary(self.css_string, end);
146
8
        let (start, end) = (start.min(end), start.max(end));
147
8
        self.css_string[start..end].trim()
148
8
    }
149
}
150

            
151
#[derive(Debug, Clone, PartialEq)]
152
pub enum CssParseErrorInner<'a> {
153
    /// A hard error in the CSS syntax
154
    ParseError(CssSyntaxError),
155
    /// Braces are not balanced properly
156
    UnclosedBlock,
157
    /// Invalid syntax, such as `#div { #div: "my-value" }`
158
    MalformedCss,
159
    /// Error parsing dynamic CSS property, such as
160
    /// `#div { width: {{ my_id }} /* no default case */ }`
161
    DynamicCssParseError(DynamicCssParseError<'a>),
162
    /// Error while parsing a pseudo selector (like `:aldkfja`)
163
    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
164
    /// The path has to be either `*`, `div`, `p` or something like that
165
    NodeTypeTag(NodeTypeTagParseError<'a>),
166
    /// A certain property has an unknown key, for example: `alsdfkj: 500px` = `unknown CSS key
167
    /// "alsdfkj: 500px"`
168
    UnknownPropertyKey(&'a str, &'a str),
169
    /// `var()` can't be used on properties that expand to multiple values, since they would be
170
    /// ambiguous and degrade performance - for example `margin: var(--blah)` would be ambiguous
171
    /// because it's not clear when setting the variable, whether all sides should be set,
172
    /// instead, you have to use `margin-top: var(--blah)`, `margin-bottom: var(--baz)` in order
173
    /// to work around this limitation.
174
    VarOnShorthandProperty {
175
        key: CombinedCssPropertyType,
176
        value: &'a str,
177
    },
178
}
179

            
180
/// Wrapper for `UnknownPropertyKey` error.
181
#[derive(Debug, Clone, PartialEq, Eq)]
182
#[repr(C)]
183
pub struct UnknownPropertyKeyError {
184
    pub key: AzString,
185
    pub value: AzString,
186
}
187

            
188
/// Wrapper for `VarOnShorthandProperty` error.
189
#[derive(Debug, Clone, PartialEq, Eq)]
190
#[repr(C)]
191
pub struct VarOnShorthandPropertyError {
192
    pub key: CombinedCssPropertyType,
193
    pub value: AzString,
194
}
195

            
196
#[derive(Debug, Clone, PartialEq)]
197
#[repr(C, u8)]
198
pub enum CssParseErrorInnerOwned {
199
    ParseError(CssSyntaxError),
200
    UnclosedBlock,
201
    MalformedCss,
202
    DynamicCssParseError(DynamicCssParseErrorOwned),
203
    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
204
    NodeTypeTag(NodeTypeTagParseErrorOwned),
205
    UnknownPropertyKey(UnknownPropertyKeyError),
206
    VarOnShorthandProperty(VarOnShorthandPropertyError),
207
}
208

            
209
impl CssParseErrorInner<'_> {
210
24
    #[must_use] pub fn to_contained(&self) -> CssParseErrorInnerOwned {
211
24
        match self {
212
4
            CssParseErrorInner::ParseError(e) => CssParseErrorInnerOwned::ParseError(*e),
213
3
            CssParseErrorInner::UnclosedBlock => CssParseErrorInnerOwned::UnclosedBlock,
214
8
            CssParseErrorInner::MalformedCss => CssParseErrorInnerOwned::MalformedCss,
215
1
            CssParseErrorInner::DynamicCssParseError(e) => {
216
1
                CssParseErrorInnerOwned::DynamicCssParseError(e.to_contained())
217
            }
218
1
            CssParseErrorInner::PseudoSelectorParseError(e) => {
219
1
                CssParseErrorInnerOwned::PseudoSelectorParseError(e.to_contained())
220
            }
221
1
            CssParseErrorInner::NodeTypeTag(e) => {
222
1
                CssParseErrorInnerOwned::NodeTypeTag(e.to_contained())
223
            }
224
5
            CssParseErrorInner::UnknownPropertyKey(a, b) => {
225
5
                CssParseErrorInnerOwned::UnknownPropertyKey(UnknownPropertyKeyError { key: (*a).to_string().into(), value: (*b).to_string().into() })
226
            }
227
1
            CssParseErrorInner::VarOnShorthandProperty { key, value } => {
228
1
                CssParseErrorInnerOwned::VarOnShorthandProperty(VarOnShorthandPropertyError {
229
1
                    key: *key,
230
1
                    value: (*value).to_string().into(),
231
1
                })
232
            }
233
        }
234
24
    }
235
}
236

            
237
impl CssParseErrorInnerOwned {
238
21
    #[must_use] pub fn to_shared(&self) -> CssParseErrorInner<'_> {
239
21
        match self {
240
3
            Self::ParseError(e) => CssParseErrorInner::ParseError(*e),
241
3
            Self::UnclosedBlock => CssParseErrorInner::UnclosedBlock,
242
8
            Self::MalformedCss => CssParseErrorInner::MalformedCss,
243
1
            Self::DynamicCssParseError(e) => {
244
1
                CssParseErrorInner::DynamicCssParseError(e.to_shared())
245
            }
246
1
            Self::PseudoSelectorParseError(e) => {
247
1
                CssParseErrorInner::PseudoSelectorParseError(e.to_shared())
248
            }
249
1
            Self::NodeTypeTag(e) => {
250
1
                CssParseErrorInner::NodeTypeTag(e.to_shared())
251
            }
252
3
            Self::UnknownPropertyKey(e) => {
253
3
                CssParseErrorInner::UnknownPropertyKey(e.key.as_str(), e.value.as_str())
254
            }
255
1
            Self::VarOnShorthandProperty(e) => {
256
1
                CssParseErrorInner::VarOnShorthandProperty {
257
1
                    key: e.key,
258
1
                    value: e.value.as_str(),
259
1
                }
260
            }
261
        }
262
21
    }
263
}
264

            
265
impl_display! { CssParseErrorInner<'a>, {
266
    ParseError(e) => format!("Parse Error: {:?}", e),
267
    UnclosedBlock => "Unclosed block",
268
    MalformedCss => "Malformed Css",
269
    DynamicCssParseError(e) => format!("{}", e),
270
    PseudoSelectorParseError(e) => format!("Failed to parse pseudo-selector: {}", e),
271
    NodeTypeTag(e) => format!("Failed to parse CSS selector path: {}", e),
272
    UnknownPropertyKey(k, v) => format!("Unknown CSS key: \"{}: {}\"", k, v),
273
    VarOnShorthandProperty { key, value } => format!(
274
        "Error while parsing: \"{}: {};\": var() cannot be used on shorthand properties - use `{}-top` or `{}-x` as the key instead: ",
275
        key, value, key, key
276
    ),
277
}}
278

            
279
impl From<CssSyntaxError> for CssParseErrorInner<'_> {
280
    fn from(e: CssSyntaxError) -> Self {
281
        CssParseErrorInner::ParseError(e)
282
    }
283
}
284

            
285
impl From<SimplecssError> for CssParseErrorInner<'_> {
286
225
    fn from(e: SimplecssError) -> Self {
287
225
        CssParseErrorInner::ParseError(CssSyntaxError::from(e))
288
225
    }
289
}
290

            
291
impl_from! { DynamicCssParseError<'a>, CssParseErrorInner::DynamicCssParseError }
292
impl_from! { NodeTypeTagParseError<'a>, CssParseErrorInner::NodeTypeTag }
293
impl_from! { CssPseudoSelectorParseError<'a>, CssParseErrorInner::PseudoSelectorParseError }
294

            
295
#[derive(Debug, Clone, PartialEq, Eq)]
296
pub enum CssPseudoSelectorParseError<'a> {
297
    EmptyNthChild,
298
    UnknownSelector(&'a str, Option<&'a str>),
299
    InvalidNthChildPattern(&'a str),
300
    InvalidNthChild(ParseIntError),
301
}
302

            
303
impl From<ParseIntError> for CssPseudoSelectorParseError<'_> {
304
58
    fn from(e: ParseIntError) -> Self {
305
58
        CssPseudoSelectorParseError::InvalidNthChild(e)
306
58
    }
307
}
308

            
309
impl_display! { CssPseudoSelectorParseError<'a>, {
310
    EmptyNthChild => format!("\
311
        Empty :nth-child() selector - nth-child() must at least take a number, \
312
        a pattern (such as \"2n+3\") or the values \"even\" or \"odd\"."
313
    ),
314
    UnknownSelector(selector, value) => {
315
        let format_str = value
316
            .as_ref()
317
            .map_or_else(|| (*selector).to_string(), |v| format!("{selector}({v})"));
318
        format!("Invalid or unknown CSS pseudo-selector: ':{format_str}'")
319
    },
320
    InvalidNthChildPattern(selector) => format!(
321
        "Invalid pseudo-selector :{} - value has to be a \
322
        number, \"even\" or \"odd\" or a pattern such as \"2n+3\"", selector
323
    ),
324
    InvalidNthChild(e) => format!("Invalid :nth-child pseudo-selector: ':{}'", e),
325
}}
326

            
327
/// Wrapper for `UnknownSelector` error.
328
#[derive(Debug, Clone, PartialEq, Eq)]
329
#[repr(C)]
330
pub struct UnknownSelectorError {
331
    pub selector: AzString,
332
    pub suggestion: OptionString,
333
}
334

            
335
#[derive(Debug, Clone, PartialEq, Eq)]
336
#[repr(C, u8)]
337
pub enum CssPseudoSelectorParseErrorOwned {
338
    EmptyNthChild,
339
    UnknownSelector(UnknownSelectorError),
340
    InvalidNthChildPattern(AzString),
341
    InvalidNthChild(crate::props::basic::error::ParseIntError),
342
}
343

            
344
impl CssPseudoSelectorParseError<'_> {
345
9
    #[must_use] pub fn to_contained(&self) -> CssPseudoSelectorParseErrorOwned {
346
9
        match self {
347
            CssPseudoSelectorParseError::EmptyNthChild => {
348
2
                CssPseudoSelectorParseErrorOwned::EmptyNthChild
349
            }
350
3
            CssPseudoSelectorParseError::UnknownSelector(a, b) => {
351
                CssPseudoSelectorParseErrorOwned::UnknownSelector(UnknownSelectorError {
352
3
                    selector: (*a).to_string().into(),
353
3
                    suggestion: b.map(|s| AzString::from(s.to_string())).into(),
354
                })
355
            }
356
2
            CssPseudoSelectorParseError::InvalidNthChildPattern(s) => {
357
2
                CssPseudoSelectorParseErrorOwned::InvalidNthChildPattern((*s).to_string().into())
358
            }
359
2
            CssPseudoSelectorParseError::InvalidNthChild(e) => {
360
2
                CssPseudoSelectorParseErrorOwned::InvalidNthChild(e.clone().into())
361
            }
362
        }
363
9
    }
364
}
365

            
366
impl CssPseudoSelectorParseErrorOwned {
367
9
    #[must_use] pub fn to_shared(&self) -> CssPseudoSelectorParseError<'_> {
368
9
        match self {
369
            Self::EmptyNthChild => {
370
2
                CssPseudoSelectorParseError::EmptyNthChild
371
            }
372
3
            Self::UnknownSelector(e) => {
373
3
                CssPseudoSelectorParseError::UnknownSelector(e.selector.as_str(), e.suggestion.as_ref().map(AzString::as_str))
374
            }
375
2
            Self::InvalidNthChildPattern(s) => {
376
2
                CssPseudoSelectorParseError::InvalidNthChildPattern(s)
377
            }
378
2
            Self::InvalidNthChild(e) => {
379
2
                CssPseudoSelectorParseError::InvalidNthChild(e.to_std())
380
            }
381
        }
382
9
    }
383
}
384

            
385
/// Error that can happen during `css_parser::parse_key_value_pair`
386
#[derive(Debug, Clone, PartialEq)]
387
pub enum DynamicCssParseError<'a> {
388
    /// The brace contents aren't valid, i.e. `var(asdlfkjasf)`
389
    InvalidBraceContents(&'a str),
390
    /// Unexpected value when parsing the string
391
    UnexpectedValue(CssParsingError<'a>),
392
}
393

            
394
impl_display! { DynamicCssParseError<'a>, {
395
    InvalidBraceContents(e) => format!("Invalid contents of var() function: var({})", e),
396
    UnexpectedValue(e) => format!("{}", e),
397
}}
398

            
399
impl<'a> From<CssParsingError<'a>> for DynamicCssParseError<'a> {
400
33363
    fn from(e: CssParsingError<'a>) -> Self {
401
33363
        DynamicCssParseError::UnexpectedValue(e)
402
33363
    }
403
}
404

            
405
#[derive(Debug, Clone, PartialEq)]
406
#[repr(C, u8)]
407
pub enum DynamicCssParseErrorOwned {
408
    InvalidBraceContents(AzString),
409
    UnexpectedValue(CssParsingErrorOwned),
410
}
411

            
412
impl DynamicCssParseError<'_> {
413
4
    #[must_use] pub fn to_contained(&self) -> DynamicCssParseErrorOwned {
414
4
        match self {
415
3
            DynamicCssParseError::InvalidBraceContents(s) => {
416
3
                DynamicCssParseErrorOwned::InvalidBraceContents((*s).to_string().into())
417
            }
418
1
            DynamicCssParseError::UnexpectedValue(e) => {
419
1
                DynamicCssParseErrorOwned::UnexpectedValue(e.to_contained())
420
            }
421
        }
422
4
    }
423
}
424

            
425
impl DynamicCssParseErrorOwned {
426
4
    #[must_use] pub fn to_shared(&self) -> DynamicCssParseError<'_> {
427
4
        match self {
428
3
            Self::InvalidBraceContents(s) => {
429
3
                DynamicCssParseError::InvalidBraceContents(s)
430
            }
431
1
            Self::UnexpectedValue(e) => {
432
1
                DynamicCssParseError::UnexpectedValue(e.to_shared())
433
            }
434
        }
435
4
    }
436
}
437

            
438
/// "selector" contains the actual selector such as "nth-child" while "value" contains
439
/// an optional value - for example "nth-child(3)" would be: selector: "nth-child", value: "3".
440
/// # Errors
441
///
442
/// Returns an error if `selector` (with optional `value`) is not a recognized CSS pseudo-selector.
443
16806
pub fn pseudo_selector_from_str<'a>(
444
16806
    selector: &'a str,
445
16806
    value: Option<&'a str>,
446
16806
) -> Result<CssPathPseudoSelector, CssPseudoSelectorParseError<'a>> {
447
16806
    match selector {
448
16806
        "first" => Ok(CssPathPseudoSelector::First),
449
16805
        "last" => Ok(CssPathPseudoSelector::Last),
450
16804
        "hover" => Ok(CssPathPseudoSelector::Hover),
451
383
        "active" => Ok(CssPathPseudoSelector::Active),
452
375
        "focus" => Ok(CssPathPseudoSelector::Focus),
453
360
        "dragging" => Ok(CssPathPseudoSelector::Dragging),
454
359
        "drag-over" => Ok(CssPathPseudoSelector::DragOver),
455
358
        "root" => Ok(CssPathPseudoSelector::Root),
456
356
        "nth-child" => {
457
7
            let value = value.ok_or(CssPseudoSelectorParseError::EmptyNthChild)?;
458
6
            let parsed = parse_nth_child_selector(value)?;
459
2
            Ok(CssPathPseudoSelector::NthChild(parsed))
460
        }
461
349
        "lang" => {
462
68
            let lang_value = value.ok_or(CssPseudoSelectorParseError::UnknownSelector(
463
68
                selector, value,
464
68
            ))?;
465
            // Remove quotes if present
466
67
            let lang_value = lang_value
467
67
                .trim()
468
67
                .trim_start_matches('"')
469
67
                .trim_end_matches('"')
470
67
                .trim_start_matches('\'')
471
67
                .trim_end_matches('\'')
472
67
                .trim();
473
67
            Ok(CssPathPseudoSelector::Lang(AzString::from(
474
67
                lang_value.to_string(),
475
67
            )))
476
        }
477
281
        _ => Err(CssPseudoSelectorParseError::UnknownSelector(
478
281
            selector, value,
479
281
        )),
480
    }
481
16806
}
482

            
483
/// Parses the inner content of an attribute selector token (the text between `[` and `]`).
484
///
485
/// Returns `None` if the input is malformed (empty name, unterminated quote, etc).
486
227
#[must_use] pub fn parse_attribute_selector(input: &str) -> Option<CssAttributeSelector> {
487
227
    let s = input.trim();
488
227
    if s.is_empty() {
489
20
        return None;
490
207
    }
491

            
492
    // Find the operator (the longest match wins): try the compound operators
493
    // first (in order), then the bare `=`, otherwise it is an existence check.
494
207
    let compound_ops: [(&str, AttributeMatchOp); 5] = [
495
207
        ("~=", AttributeMatchOp::Includes),
496
207
        ("|=", AttributeMatchOp::DashMatch),
497
207
        ("^=", AttributeMatchOp::Prefix),
498
207
        ("$=", AttributeMatchOp::Suffix),
499
207
        ("*=", AttributeMatchOp::Substring),
500
207
    ];
501
207
    let (op, op_pos): (AttributeMatchOp, Option<usize>) = compound_ops
502
207
        .iter()
503
881
        .find_map(|(pat, op)| s.find(pat).map(|i| (*op, Some(i))))
504
207
        .or_else(|| s.find('=').map(|i| (AttributeMatchOp::Eq, Some(i))))
505
207
        .unwrap_or((AttributeMatchOp::Exists, None));
506

            
507
207
    let (name, value) = match op_pos {
508
53
        None => (s, None),
509
154
        Some(i) => {
510
154
            let name = s[..i].trim();
511
154
            let op_len = if matches!(op, AttributeMatchOp::Eq) { 1 } else { 2 };
512
154
            let raw_value = s[i + op_len..].trim();
513
154
            let unquoted = strip_attribute_quotes(raw_value)?;
514
132
            (name, Some(unquoted))
515
        }
516
    };
517

            
518
185
    if name.is_empty() {
519
3
        return None;
520
182
    }
521
    // Reject names that contain whitespace or quotes.
522
979
    if name.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') {
523
6
        return None;
524
176
    }
525

            
526
    Some(CssAttributeSelector {
527
176
        name: name.to_string().into(),
528
176
        op,
529
176
        value: value
530
176
            .map_or_else(|| OptionString::None, |v| OptionString::Some(v.to_string().into())),
531
    })
532
227
}
533

            
534
/// Strips matching surrounding `"` or `'` from a value. If the value is unquoted,
535
/// returns it unchanged. Returns `None` if quoting is unbalanced.
536
178
fn strip_attribute_quotes(s: &str) -> Option<&str> {
537
178
    let bytes = s.as_bytes();
538
178
    if bytes.len() >= 2 {
539
161
        let first = bytes[0];
540
161
        let last = bytes[bytes.len() - 1];
541
161
        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
542
119
            return Some(&s[1..s.len() - 1]);
543
42
        }
544
42
        if first == b'"' || first == b'\'' || last == b'"' || last == b'\'' {
545
            // Unbalanced quote.
546
25
            return None;
547
17
        }
548
17
    } else if bytes.len() == 1 && (bytes[0] == b'"' || bytes[0] == b'\'') {
549
4
        return None;
550
13
    }
551
30
    Some(s)
552
178
}
553

            
554
/// Parses the inner value of the `:nth-child` selector, including numbers and patterns.
555
///
556
/// I.e.: `"2n+3"` -> `Pattern { repeat: 2, offset: 3 }`
557
43
fn parse_nth_child_selector(
558
43
    value: &str,
559
43
) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'_>> {
560
43
    let value = value.trim();
561

            
562
43
    if value.is_empty() {
563
2
        return Err(CssPseudoSelectorParseError::EmptyNthChild);
564
41
    }
565

            
566
41
    if let Ok(number) = value.parse::<u32>() {
567
7
        return Ok(CssNthChildSelector::Number(number));
568
34
    }
569

            
570
    // If the value is not a number
571
34
    match value {
572
34
        "even" => Ok(CssNthChildSelector::Even),
573
33
        "odd" => Ok(CssNthChildSelector::Odd),
574
32
        _ => parse_nth_child_pattern(value),
575
    }
576
43
}
577

            
578
/// Parses the pattern between the braces of a "nth-child" (such as "2n+3").
579
68
fn parse_nth_child_pattern(
580
68
    value: &str,
581
68
) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'_>> {
582
    use crate::css::CssNthChildPattern;
583

            
584
68
    let value = value.trim();
585

            
586
68
    if value.is_empty() {
587
4
        return Err(CssPseudoSelectorParseError::EmptyNthChild);
588
64
    }
589

            
590
    // TODO: Test for "+"
591
64
    let repeat = value
592
64
        .split('n')
593
64
        .next()
594
64
        .ok_or(CssPseudoSelectorParseError::InvalidNthChildPattern(value))?
595
64
        .trim()
596
64
        .parse::<u32>()?;
597

            
598
    // In a "2n+3" form, the first .next() yields the "2n", the second .next() yields the "3"
599
7
    let mut offset_iterator = value.split('+');
600

            
601
    // has to succeed, since the string is verified to not be empty
602
7
    offset_iterator.next().unwrap();
603

            
604
7
    let offset = match offset_iterator.next() {
605
4
        Some(offset_string) => {
606
4
            let offset_string = offset_string.trim();
607
4
            if offset_string.is_empty() {
608
2
                return Err(CssPseudoSelectorParseError::InvalidNthChildPattern(value));
609
2
            }
610
2
            offset_string.parse::<u32>()?
611
        }
612
3
        None => 0,
613
    };
614

            
615
4
    Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
616
4
        pattern_repeat: repeat,
617
4
        offset,
618
4
    }))
619
68
}
620

            
621
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
622
#[repr(C)]
623
pub struct ErrorLocation {
624
    pub original_pos: usize,
625
}
626

            
627
/// FFI-safe replacement for `(ErrorLocation, ErrorLocation)` tuple.
628
/// Represents a range (start..end) in the source text.
629
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
630
#[repr(C)]
631
pub struct ErrorLocationRange {
632
    pub start: ErrorLocation,
633
    pub end: ErrorLocation,
634
}
635

            
636
impl ErrorLocation {
637
    /// Given an error location, returns the (line, column)
638
23
    #[must_use] pub fn get_line_column_from_error(&self, css_string: &str) -> (usize, usize) {
639
        // `original_pos` is a pub field and, at Token::EndOfStream, `get_error_location`
640
        // records it as exactly `css_string.len()` -- so `- 1` lands INSIDE the final
641
        // character whenever the stylesheet ends in a multi-byte char, and an
642
        // out-of-range value is trivially constructible. Both used to panic here, i.e.
643
        // simply Display-ing a parse error on Unicode CSS would abort.
644
23
        let error_location =
645
23
            clamp_to_char_boundary(css_string, self.original_pos.saturating_sub(1));
646
23
        let (mut line_number, mut total_characters) = (0, 0);
647

            
648
2762
        for line in css_string[0..error_location].lines() {
649
2762
            line_number += 1;
650
2762
            total_characters += line.chars().count();
651
2762
        }
652

            
653
        // Rust doesn't count "\n" as a character, so we have to add the line number count on top
654
23
        let total_characters = total_characters + line_number;
655
23
        let column_pos = error_location - total_characters.saturating_sub(2);
656

            
657
23
        (line_number, column_pos)
658
23
    }
659
}
660

            
661
impl fmt::Display for CssParseError<'_> {
662
3
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663
3
        let start_location = self.location.start.get_line_column_from_error(self.css_string);
664
3
        let end_location = self.location.end.get_line_column_from_error(self.css_string);
665
3
        write!(
666
3
            f,
667
3
            "    start: line {}:{}\r\n    end: line {}:{}\r\n    text: \"{}\"\r\n    reason: {}",
668
            start_location.0,
669
            start_location.1,
670
            end_location.0,
671
            end_location.1,
672
3
            self.get_error_string(),
673
            self.error,
674
        )
675
3
    }
676
}
677

            
678
/// Parses a CSS string into a [`Css`] value and a list of recoverable warnings.
679
///
680
/// Never panics. Syntax errors and unsupported properties are collected as
681
/// [`CssParseWarnMsg`] items rather than causing a hard failure, so the caller
682
/// always receives a (possibly empty) stylesheet.
683
63181
#[must_use] pub fn new_from_str(css_string: &str) -> (Css, Vec<CssParseWarnMsg<'_>>) {
684
    // ONE tokenizer pass. `@keyframes` rides `azul_simplecss`'s native
685
    // at-rule handling (`AtRule("keyframes")` + `AtStr(name)` + the nesting
686
    // stack): the main loop switches into a stop-collection mode at the
687
    // block's `{` and back out at its matching `}`. Percent stop selectors
688
    // (`50%`, `62.5%, to`) tokenize natively since azul-simplecss 0.2.1 —
689
    // the old TEXTUAL pre-extraction (find("@keyframes") + segment
690
    // stitching) is gone, which also means `@keyframes` inside `@media` now
691
    // PARSES (its keyframes join the flat list; the enclosing conditions do
692
    // not gate keyframes yet) and a commented-out `@keyframes` is no longer
693
    // seen at all.
694
63181
    let mut tokenizer = Tokenizer::new(css_string);
695
63181
    let mut keyframes: Vec<crate::css::Keyframes> = Vec::new();
696
63181
    let (rules, warnings) = new_from_str_inner(css_string, &mut tokenizer, &mut keyframes);
697
63181
    (
698
63181
        Css { rules: rules.into(), keyframes: keyframes.into() },
699
63181
        warnings,
700
63181
    )
701
63181
}
702

            
703
/// Map a keyframe stop selector to permille: `from` = 0, `to` = 1000,
704
/// `<number>%` in `0..=100` = rounded tenths. Unknown selectors return
705
/// `None` and are SKIPPED, matching the rule parser's warn-and-continue
706
/// posture.
707
800
fn stop_selector_permille(sel: &str) -> Option<u16> {
708
800
    match sel {
709
800
        "from" => Some(0),
710
402
        "to" => Some(1000),
711
3
        s => s.strip_suffix('%').and_then(|n| {
712
3
            n.trim().parse::<f32>().ok().and_then(|pct| {
713
3
                if (0.0..=100.0).contains(&pct) {
714
                    // Range-guarded above: 0.0..=100.0 * 10 rounds into 0..=1000.
715
                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
716
3
                    Some((pct * 10.0).round() as u16)
717
                } else {
718
                    None
719
                }
720
3
            })
721
3
        }),
722
    }
723
800
}
724

            
725
/// Returns the location of where the parser is currently in the document
726
1575525
fn get_error_location(tokenizer: &Tokenizer<'_>) -> ErrorLocation {
727
1575525
    ErrorLocation {
728
1575525
        original_pos: tokenizer.pos(),
729
1575525
    }
730
1575525
}
731

            
732
#[derive(Debug, Clone, PartialEq, Eq)]
733
pub enum CssPathParseError<'a> {
734
    EmptyPath,
735
    /// Invalid item encountered in string (for example a "{", "}")
736
    InvalidTokenEncountered(&'a str),
737
    UnexpectedEndOfStream(&'a str),
738
    SyntaxError(CssSyntaxError),
739
    /// The path has to be either `*`, `div`, `p` or something like that
740
    NodeTypeTag(NodeTypeTagParseError<'a>),
741
    /// Error while parsing a pseudo selector (like `:aldkfja`)
742
    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
743
}
744

            
745
impl_from! { NodeTypeTagParseError<'a>, CssPathParseError::NodeTypeTag }
746
impl_from! { CssPseudoSelectorParseError<'a>, CssPathParseError::PseudoSelectorParseError }
747

            
748
impl From<CssSyntaxError> for CssPathParseError<'_> {
749
    fn from(e: CssSyntaxError) -> Self {
750
        CssPathParseError::SyntaxError(e)
751
    }
752
}
753

            
754
impl From<SimplecssError> for CssPathParseError<'_> {
755
18
    fn from(e: SimplecssError) -> Self {
756
18
        CssPathParseError::SyntaxError(CssSyntaxError::from(e))
757
18
    }
758
}
759

            
760
#[derive(Debug, Clone, PartialEq, Eq)]
761
pub enum CssPathParseErrorOwned {
762
    EmptyPath,
763
    InvalidTokenEncountered(AzString),
764
    UnexpectedEndOfStream(AzString),
765
    SyntaxError(CssSyntaxError),
766
    NodeTypeTag(NodeTypeTagParseErrorOwned),
767
    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
768
}
769

            
770
impl CssPathParseError<'_> {
771
7
    #[must_use] pub fn to_contained(&self) -> CssPathParseErrorOwned {
772
7
        match self {
773
1
            CssPathParseError::EmptyPath => CssPathParseErrorOwned::EmptyPath,
774
2
            CssPathParseError::InvalidTokenEncountered(s) => {
775
2
                CssPathParseErrorOwned::InvalidTokenEncountered((*s).to_string().into())
776
            }
777
1
            CssPathParseError::UnexpectedEndOfStream(s) => {
778
1
                CssPathParseErrorOwned::UnexpectedEndOfStream((*s).to_string().into())
779
            }
780
1
            CssPathParseError::SyntaxError(e) => CssPathParseErrorOwned::SyntaxError(*e),
781
1
            CssPathParseError::NodeTypeTag(e) => {
782
1
                CssPathParseErrorOwned::NodeTypeTag(e.to_contained())
783
            }
784
1
            CssPathParseError::PseudoSelectorParseError(e) => {
785
1
                CssPathParseErrorOwned::PseudoSelectorParseError(e.to_contained())
786
            }
787
        }
788
7
    }
789
}
790

            
791
impl CssPathParseErrorOwned {
792
7
    #[must_use] pub fn to_shared(&self) -> CssPathParseError<'_> {
793
7
        match self {
794
1
            Self::EmptyPath => CssPathParseError::EmptyPath,
795
2
            Self::InvalidTokenEncountered(s) => {
796
2
                CssPathParseError::InvalidTokenEncountered(s)
797
            }
798
1
            Self::UnexpectedEndOfStream(s) => {
799
1
                CssPathParseError::UnexpectedEndOfStream(s)
800
            }
801
1
            Self::SyntaxError(e) => CssPathParseError::SyntaxError(*e),
802
1
            Self::NodeTypeTag(e) => CssPathParseError::NodeTypeTag(e.to_shared()),
803
1
            Self::PseudoSelectorParseError(e) => {
804
1
                CssPathParseError::PseudoSelectorParseError(e.to_shared())
805
            }
806
        }
807
7
    }
808
}
809

            
810
/// Parses a CSS path from a string (only the path,.no commas allowed)
811
///
812
/// ```rust
813
/// # extern crate azul_css;
814
/// # use azul_css::parser2::parse_css_path;
815
/// # use azul_css::css::{
816
/// #     CssPathSelector::*, CssPathPseudoSelector::*, CssPath,
817
/// #     NodeTypeTag::*, CssNthChildSelector::*
818
/// # };
819
///
820
/// assert_eq!(
821
///     parse_css_path("* div #my_id > .class:nth-child(2)"),
822
///     Ok(CssPath {
823
///         selectors: vec![
824
///             Global,
825
///             Type(Div),
826
///             Children,
827
///             Id("my_id".to_string().into()),
828
///             DirectChildren,
829
///             Class("class".to_string().into()),
830
///             PseudoSelector(NthChild(Number(2))),
831
///         ]
832
///         .into()
833
///     })
834
/// );
835
/// ```
836
/// # Errors
837
///
838
/// Returns an error if `input` is not a valid CSS `css-path` value.
839
1657
pub fn parse_css_path(input: &str) -> Result<CssPath, CssPathParseError<'_>> {
840
    use azul_simplecss::{Combinator, Token};
841

            
842
1657
    let input = input.trim();
843
1657
    if input.is_empty() {
844
6
        return Err(CssPathParseError::EmptyPath);
845
1651
    }
846

            
847
1651
    let mut tokenizer = Tokenizer::new(input);
848
1651
    let mut selectors = Vec::new();
849

            
850
    loop {
851
103276
        let token = tokenizer.parse_next()?;
852
50003
        match token {
853
1
            Token::UniversalSelector => {
854
1
                selectors.push(CssPathSelector::Global);
855
1
            }
856
50024
            Token::TypeSelector(div_type) => match NodeTypeTag::from_str(div_type) {
857
                // An unknown type selector must invalidate the whole path (Selectors L4:
858
                // an invalid simple selector invalidates the selector), not be silently
859
                // dropped — dropping it left a dangling combinator that matched every
860
                // descendant of the previous selector.
861
50009
                Ok(nt) => selectors.push(CssPathSelector::Type(nt)),
862
15
                Err(e) => return Err(CssPathParseError::NodeTypeTag(e)),
863
            },
864
1417
            Token::IdSelector(id) => {
865
1417
                selectors.push(CssPathSelector::Id(id.to_string().into()));
866
1417
            }
867
194
            Token::ClassSelector(class) => {
868
194
                selectors.push(CssPathSelector::Class(class.to_string().into()));
869
194
            }
870
1
            Token::Combinator(Combinator::GreaterThan) => {
871
1
                selectors.push(CssPathSelector::DirectChildren);
872
1
            }
873
50002
            Token::Combinator(Combinator::Space) => {
874
50002
                selectors.push(CssPathSelector::Children);
875
50002
            }
876
            Token::Combinator(Combinator::Plus) => {
877
                selectors.push(CssPathSelector::AdjacentSibling);
878
            }
879
            Token::Combinator(Combinator::Tilde) => {
880
                selectors.push(CssPathSelector::GeneralSibling);
881
            }
882
3
            Token::PseudoClass { selector, value } => {
883
3
                selectors.push(CssPathSelector::PseudoSelector(pseudo_selector_from_str(
884
3
                    selector, value,
885
2
                )?));
886
            }
887
            Token::EndOfStream => {
888
1612
                break;
889
            }
890
            _ => {
891
4
                return Err(CssPathParseError::InvalidTokenEncountered(input));
892
            }
893
        }
894
    }
895

            
896
1612
    if selectors.is_empty() {
897
1
        Err(CssPathParseError::EmptyPath)
898
    } else {
899
1611
        Ok(CssPath {
900
1611
            selectors: selectors.into(),
901
1611
        })
902
    }
903
1657
}
904

            
905
#[derive(Debug, Clone, PartialEq, Eq)]
906
pub struct UnparsedCssRuleBlock<'a> {
907
    /// The css path (full selector) of the style ruleset
908
    pub path: CssPath,
909
    /// `"justify-content" => "center"`
910
    pub declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
911
    /// Conditions from enclosing @-rules (@media, @lang, etc.)
912
    pub conditions: Vec<DynamicSelector>,
913
}
914

            
915
/// Owned version of `UnparsedCssRuleBlock`, with `BTreeMap` of Strings.
916
#[derive(Debug, Clone, PartialEq, Eq)]
917
pub struct UnparsedCssRuleBlockOwned {
918
    pub path: CssPath,
919
    pub declarations: BTreeMap<String, (String, ErrorLocationRange)>,
920
    pub conditions: Vec<DynamicSelector>,
921
}
922

            
923
impl UnparsedCssRuleBlock<'_> {
924
2
    #[must_use] pub fn to_contained(&self) -> UnparsedCssRuleBlockOwned {
925
        UnparsedCssRuleBlockOwned {
926
2
            path: self.path.clone(),
927
2
            declarations: self
928
2
                .declarations
929
2
                .iter()
930
2
                .map(|(k, (v, loc))| ((*k).to_string(), ((*v).to_string(), *loc)))
931
2
                .collect(),
932
2
            conditions: self.conditions.clone(),
933
        }
934
2
    }
935
}
936

            
937
impl UnparsedCssRuleBlockOwned {
938
2
    #[must_use] pub fn to_shared(&self) -> UnparsedCssRuleBlock<'_> {
939
        UnparsedCssRuleBlock {
940
2
            path: self.path.clone(),
941
2
            declarations: self
942
2
                .declarations
943
2
                .iter()
944
2
                .map(|(k, (v, loc))| (k.as_str(), (v.as_str(), *loc)))
945
2
                .collect(),
946
2
            conditions: self.conditions.clone(),
947
        }
948
2
    }
949
}
950

            
951
#[derive(Debug, Clone, PartialEq)]
952
pub struct CssParseWarnMsg<'a> {
953
    pub warning: CssParseWarnMsgInner<'a>,
954
    pub location: ErrorLocationRange,
955
}
956

            
957
/// Owned version of `CssParseWarnMsg`, where warning is the owned type.
958
#[derive(Debug, Clone, PartialEq)]
959
pub struct CssParseWarnMsgOwned {
960
    pub warning: CssParseWarnMsgInnerOwned,
961
    pub location: ErrorLocationRange,
962
}
963

            
964
impl CssParseWarnMsg<'_> {
965
7
    #[must_use] pub fn to_contained(&self) -> CssParseWarnMsgOwned {
966
7
        CssParseWarnMsgOwned {
967
7
            warning: self.warning.to_contained(),
968
7
            location: self.location,
969
7
        }
970
7
    }
971
}
972

            
973
impl CssParseWarnMsgOwned {
974
7
    #[must_use] pub fn to_shared(&self) -> CssParseWarnMsg<'_> {
975
7
        CssParseWarnMsg {
976
7
            warning: self.warning.to_shared(),
977
7
            location: self.location,
978
7
        }
979
7
    }
980
}
981

            
982
#[derive(Debug, Clone, PartialEq)]
983
pub enum CssParseWarnMsgInner<'a> {
984
    /// Key "blah" isn't (yet) supported, so the parser didn't attempt to parse the value at all
985
    UnsupportedKeyValuePair { key: &'a str, value: &'a str },
986
    /// A CSS parse error that was encountered but recovered from
987
    ParseError(CssParseErrorInner<'a>),
988
    /// A rule was skipped due to an error
989
    SkippedRule {
990
        selector: Option<&'a str>,
991
        error: CssParseErrorInner<'a>,
992
    },
993
    /// A declaration was skipped due to an error
994
    SkippedDeclaration {
995
        key: &'a str,
996
        value: &'a str,
997
        error: CssParseErrorInner<'a>,
998
    },
999
    /// Malformed block structure (mismatched braces, etc.)
    MalformedStructure { message: &'a str },
}
#[derive(Debug, Clone, PartialEq)]
pub enum CssParseWarnMsgInnerOwned {
    UnsupportedKeyValuePair {
        key: String,
        value: String,
    },
    ParseError(CssParseErrorInnerOwned),
    SkippedRule {
        selector: Option<String>,
        error: CssParseErrorInnerOwned,
    },
    SkippedDeclaration {
        key: String,
        value: String,
        error: CssParseErrorInnerOwned,
    },
    MalformedStructure {
        message: String,
    },
}
impl CssParseWarnMsgInner<'_> {
17
    #[must_use] pub fn to_contained(&self) -> CssParseWarnMsgInnerOwned {
17
        match self {
4
            Self::UnsupportedKeyValuePair { key, value } => {
4
                CssParseWarnMsgInnerOwned::UnsupportedKeyValuePair {
4
                    key: (*key).to_string(),
4
                    value: (*value).to_string(),
4
                }
            }
3
            Self::ParseError(e) => CssParseWarnMsgInnerOwned::ParseError(e.to_contained()),
4
            Self::SkippedRule { selector, error } => CssParseWarnMsgInnerOwned::SkippedRule {
4
                selector: selector.map(std::string::ToString::to_string),
4
                error: error.to_contained(),
4
            },
4
            Self::SkippedDeclaration { key, value, error } => {
4
                CssParseWarnMsgInnerOwned::SkippedDeclaration {
4
                    key: (*key).to_string(),
4
                    value: (*value).to_string(),
4
                    error: error.to_contained(),
4
                }
            }
2
            Self::MalformedStructure { message } => CssParseWarnMsgInnerOwned::MalformedStructure {
2
                message: (*message).to_string(),
2
            },
        }
17
    }
}
impl CssParseWarnMsgInnerOwned {
14
    #[must_use] pub fn to_shared(&self) -> CssParseWarnMsgInner<'_> {
14
        match self {
4
            Self::UnsupportedKeyValuePair { key, value } => {
4
                CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value }
            }
2
            Self::ParseError(e) => CssParseWarnMsgInner::ParseError(e.to_shared()),
4
            Self::SkippedRule { selector, error } => CssParseWarnMsgInner::SkippedRule {
4
                selector: selector.as_deref(),
4
                error: error.to_shared(),
4
            },
2
            Self::SkippedDeclaration { key, value, error } => {
2
                CssParseWarnMsgInner::SkippedDeclaration {
2
                    key,
2
                    value,
2
                    error: error.to_shared(),
2
                }
            }
2
            Self::MalformedStructure { message } => {
2
                CssParseWarnMsgInner::MalformedStructure { message }
            }
        }
14
    }
}
impl_display! { CssParseWarnMsgInner<'a>, {
    UnsupportedKeyValuePair { key, value } => format!("Unsupported CSS property: \"{}: {}\"", key, value),
    ParseError(e) => format!("Parse error (recoverable): {}", e),
    SkippedRule { selector, error } => {
        let sel = selector.unwrap_or("unknown");
        format!("Skipped rule for selector '{sel}': {error}")
    },
    SkippedDeclaration { key, value, error } => format!("Skipped declaration '{}:{}': {}", key, value, error),
    MalformedStructure { message } => format!("Malformed CSS structure: {}", message),
}}
/// Parses @media conditions from the content following "@media"
/// Returns a list of `DynamicSelectors` for the conditions
256
fn parse_media_conditions(content: &str) -> Vec<DynamicSelector> {
256
    let mut conditions = Vec::new();
256
    let content = content.trim();
    // Handle simple media types: "screen", "print", "all"
256
    if content.eq_ignore_ascii_case("screen") {
44
        conditions.push(DynamicSelector::Media(MediaType::Screen));
44
        return conditions;
212
    }
212
    if content.eq_ignore_ascii_case("print") {
8
        conditions.push(DynamicSelector::Media(MediaType::Print));
8
        return conditions;
204
    }
204
    if content.eq_ignore_ascii_case("all") {
8
        conditions.push(DynamicSelector::Media(MediaType::All));
8
        return conditions;
196
    }
    // Parse more complex media queries like "(min-width: 800px)" or "screen and (max-width: 600px)"
    // Split by "and" for compound queries
20210
    for part in content.split(" and ") {
20210
        let part = part.trim();
        // Skip media type keywords in compound queries
20210
        if part.eq_ignore_ascii_case("screen")
196
            || part.eq_ignore_ascii_case("print")
196
            || part.eq_ignore_ascii_case("all")
        {
20014
            if part.eq_ignore_ascii_case("screen") {
20014
                conditions.push(DynamicSelector::Media(MediaType::Screen));
20014
            } else if part.eq_ignore_ascii_case("print") {
                conditions.push(DynamicSelector::Media(MediaType::Print));
            } else if part.eq_ignore_ascii_case("all") {
                conditions.push(DynamicSelector::Media(MediaType::All));
            }
20014
            continue;
196
        }
        // Parse parenthesized conditions like "(min-width: 800px)"
196
        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
185
            if let Some(selector) = parse_media_feature(inner) {
183
                conditions.push(selector);
183
            }
11
        }
    }
196
    conditions
256
}
/// Parses a single media feature like "min-width: 800px"
204
fn parse_media_feature(feature: &str) -> Option<DynamicSelector> {
204
    let parts: Vec<&str> = feature.splitn(2, ':').collect();
204
    if parts.len() != 2 {
        // Handle features without values like "orientation: portrait"
5
        return None;
199
    }
199
    let key = parts[0].trim();
199
    let value = parts[1].trim();
199
    match key.to_lowercase().as_str() {
199
        "min-width" => {
84
            if let Some(px) = parse_px_value(value) {
79
                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
79
                    Some(px),
79
                    None,
79
                )));
5
            }
        }
115
        "max-width" => {
77
            if let Some(px) = parse_px_value(value) {
76
                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
76
                    None,
76
                    Some(px),
76
                )));
1
            }
        }
38
        "min-height" => {
7
            if let Some(px) = parse_px_value(value) {
7
                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
7
                    Some(px),
7
                    None,
7
                )));
            }
        }
31
        "max-height" => {
8
            if let Some(px) = parse_px_value(value) {
8
                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
8
                    None,
8
                    Some(px),
8
                )));
            }
        }
23
        "orientation" => {
17
            if value.eq_ignore_ascii_case("portrait") {
8
                return Some(DynamicSelector::Orientation(OrientationType::Portrait));
9
            } else if value.eq_ignore_ascii_case("landscape") {
8
                return Some(DynamicSelector::Orientation(OrientationType::Landscape));
1
            }
        }
6
        "prefers-color-scheme" => {
1
            if value.eq_ignore_ascii_case("dark") {
1
                return Some(DynamicSelector::Theme(ThemeCondition::Dark));
            } else if value.eq_ignore_ascii_case("light") {
                return Some(DynamicSelector::Theme(ThemeCondition::Light));
            }
        }
5
        "prefers-reduced-motion" => {
1
            if value.eq_ignore_ascii_case("reduce") {
1
                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True));
            } else if value.eq_ignore_ascii_case("no-preference") {
                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::False));
            }
        }
4
        "prefers-contrast" | "prefers-high-contrast" => {
1
            if value.eq_ignore_ascii_case("more") || value.eq_ignore_ascii_case("high") || value.eq_ignore_ascii_case("active") {
1
                return Some(DynamicSelector::PrefersHighContrast(BoolCondition::True));
            } else if value.eq_ignore_ascii_case("no-preference") || value.eq_ignore_ascii_case("none") {
                return Some(DynamicSelector::PrefersHighContrast(BoolCondition::False));
            }
        }
3
        "aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), Some(ratio))));
            }
        }
3
        "min-aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), None)));
            }
        }
3
        "max-aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(ratio))));
            }
        }
3
        _ => {}
    }
10
    None
204
}
/// Parses a pixel value like "800px" and returns the numeric value
211
fn parse_px_value(value: &str) -> Option<f32> {
211
    let value = value.trim();
211
    value
211
        .strip_suffix("px")
211
        .map_or_else(
            // Try parsing as a bare number
25
            || value.parse::<f32>().ok(),
186
            |num_str| num_str.trim().parse::<f32>().ok(),
        )
        // `str::parse::<f32>` accepts "NaN"/"inf"/"infinity"; the CSS <number-token>
        // grammar does not (CSS Syntax L3 §4.3.6 — digits, no keywords). Letting a NaN
        // through is not merely lax: `MinMaxRange` encodes "no bound" AS NaN, so
        // `@media (min-width: NaN)` would silently become an unconditional match
        // instead of an invalid feature. Reject non-finite at the source.
211
        .filter(|v| v.is_finite())
211
}
/// Parses a ratio value like "16/9" or "1.777" and returns it as f32
23
fn parse_ratio_value(value: &str) -> Option<f32> {
23
    let value = value.trim();
23
    if let Some((num, den)) = value.split_once('/') {
17
        let num: f32 = num.trim().parse().ok()?;
14
        let den: f32 = den.trim().parse().ok()?;
12
        if den == 0.0 { return None; }
        // Same NaN-sentinel hazard as parse_px_value: "inf/inf" and "1/NaN" both parse,
        // and a NaN ratio reads back out of MinMaxRange as "no bound".
8
        Some(num / den).filter(|r| r.is_finite())
    } else {
6
        value.parse::<f32>().ok().filter(|r| r.is_finite())
    }
23
}
/// Parses @container conditions from the content following "@container"
/// Format: @container (min-width: 400px) or @container sidebar (min-width: 400px)
38
fn parse_container_conditions(content: &str) -> Vec<DynamicSelector> {
38
    let mut conditions = Vec::new();
38
    let content = content.trim();
    // Check if there's a container name before the parenthesized condition
    // e.g., "sidebar (min-width: 400px)" or just "(min-width: 400px)"
38
    let (name_part, query_part) = if content.starts_with('(') {
4
        (None, content)
34
    } else if let Some(paren_idx) = content.find('(') {
2
        let name = content[..paren_idx].trim();
2
        if name.is_empty() {
            (None, content)
        } else {
2
            (Some(name), &content[paren_idx..])
        }
    } else {
        // No parentheses - might be just a container name
32
        if !content.is_empty() {
27
            conditions.push(DynamicSelector::ContainerName(AzString::from(content.to_string())));
27
        }
32
        return conditions;
    };
6
    if let Some(name) = name_part {
2
        conditions.push(DynamicSelector::ContainerName(AzString::from(name.to_string())));
4
    }
    // Parse the parenthesized query parts
6
    for part in query_part.split(" and ") {
6
        let part = part.trim();
6
        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
4
            if let Some(selector) = parse_container_feature(inner) {
3
                conditions.push(selector);
3
            }
2
        }
    }
6
    conditions
38
}
/// Parses a single container query feature like "min-width: 400px"
12
fn parse_container_feature(feature: &str) -> Option<DynamicSelector> {
12
    let (key, value) = feature.split_once(':')?;
8
    let key = key.trim();
8
    let value = value.trim();
8
    match key.to_lowercase().as_str() {
8
        "min-width" => {
5
            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(Some(px), None)))
        }
3
        "max-width" => {
1
            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(None, Some(px))))
        }
2
        "min-height" => {
1
            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(Some(px), None)))
        }
1
        "max-height" => {
            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(None, Some(px))))
        }
1
        "aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), Some(r))))
        }
1
        "min-aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), None)))
        }
1
        "max-aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(r))))
        }
1
        _ => None,
    }
12
}
/// Parses @theme condition from the content following "@theme"
/// Format: @theme(dark) or @theme dark
18
fn parse_theme_condition(content: &str) -> Option<DynamicSelector> {
18
    let content = content.trim();
18
    let inner = content
18
        .strip_prefix('(')
18
        .and_then(|s| s.strip_suffix(')'))
18
        .unwrap_or(content)
18
        .trim();
18
    let inner = inner
18
        .strip_prefix('"')
18
        .and_then(|s| s.strip_suffix('"'))
18
        .or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
18
        .unwrap_or(inner)
18
        .trim();
18
    match inner.to_lowercase().as_str() {
18
        "dark" => Some(DynamicSelector::Theme(ThemeCondition::Dark)),
10
        "light" => Some(DynamicSelector::Theme(ThemeCondition::Light)),
9
        _ => None,
    }
18
}
/// Parses @lang condition from the content following "@lang"
/// Format: @lang("de-DE") or @lang(de-DE)
42
fn parse_lang_condition(content: &str) -> Option<DynamicSelector> {
42
    let content = content.trim();
    // Remove parentheses and quotes
42
    let lang = content
42
        .strip_prefix('(')
42
        .and_then(|s| s.strip_suffix(')'))
42
        .unwrap_or(content)
42
        .trim();
42
    let lang = lang
42
        .strip_prefix('"')
42
        .and_then(|s| s.strip_suffix('"'))
42
        .or_else(|| lang.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
42
        .unwrap_or(lang)
42
        .trim();
42
    if lang.is_empty() {
9
        return None;
33
    }
    // Use Prefix matching by default (e.g., "de" matches "de-DE", "de-AT")
33
    Some(DynamicSelector::Language(LanguageCondition::Prefix(
33
        AzString::from(lang.to_string()),
33
    )))
42
}
/// Parses a CSS string (single-threaded) and returns the parsed rules in blocks
///
/// May return "warning" messages, i.e. messages that just serve as a warning,
/// instead of being actual errors. These warnings may be ignored by the caller,
/// but can be useful for debugging.
// Beyond this CSS nesting depth, get_parent_paths clones the ever-growing
// ancestor path every level (parse becomes O(depth^2) — a hang on adversarial
// input like `div{` x 10_000), so deeper rules keep only their own local
// selector. No realistic stylesheet nests this deep; this bounds parse time.
const MAX_NESTING_DEPTH: usize = 1024;
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose CSS parser/formatter/dispatch table (one branch per property/variant)
63182
fn new_from_str_inner<'a>(
63182
    css_string: &'a str,
63182
    tokenizer: &mut Tokenizer<'a>,
63182
    keyframes_out: &mut Vec<crate::css::Keyframes>,
63182
) -> (Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>) {
    use azul_simplecss::{Combinator, Token};
    // Stack entry for nested selectors: accumulated parent paths + the current
    // declarations at this nesting level.
    struct NestingLevel<'a> {
        paths: Vec<Vec<CssPathSelector>>,
        declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
        depth: usize,
    }
    // Helper: get parent paths from nesting stack (if any)
113699
    fn get_parent_paths(nesting_stack: &[NestingLevel<'_>]) -> Vec<Vec<CssPathSelector>> {
113699
        nesting_stack
113699
            .last()
113699
            .map_or_else(Vec::new, |parent| parent.paths.clone())
113699
    }
    // Helper: combine parent path with child selector for nesting
    // For .button { :hover { } } -> .button:hover
    // For .outer { .inner { } } -> .outer .inner (with Children combinator)
    fn combine_paths(
        parent_paths: &[Vec<CssPathSelector>],
        child_path: &[CssPathSelector],
        is_pseudo_only: bool,
    ) -> Vec<Vec<CssPathSelector>> {
        if parent_paths.is_empty() {
            vec![child_path.to_vec()]
        } else {
            parent_paths
                .iter()
                .map(|parent| {
                    let mut combined = parent.clone();
                    if !is_pseudo_only && !child_path.is_empty() {
                        // Add implicit descendant combinator for non-pseudo selectors
                        combined.push(CssPathSelector::Children);
                    }
                    combined.extend(child_path.iter().cloned());
                    combined
                })
                .collect()
        }
    }
    // `@keyframes` stop-collection mode state. While active, ALL tokens are
    // routed to it (stop selectors are `from`/`to`/`NN%` type selectors,
    // their blocks hold ordinary declarations) until the at-rule's own
    // closing brace pops the capture into `keyframes_out`.
    struct KfCapture {
        name: String,
        stops: Vec<crate::css::KeyframeStop>,
        selectors: Vec<String>,
        props: Vec<crate::props::property::CssProperty>,
        in_stop: bool,
    }
63182
    let mut css_blocks = Vec::new();
63182
    let mut warnings = Vec::new();
63182
    let mut block_nesting = 0_usize;
63182
    let mut last_path: Vec<CssPathSelector> = Vec::new();
63182
    let mut last_error_location = ErrorLocation { original_pos: 0 };
    // Stack for tracking @-rule conditions (e.g., @media, @lang, @os)
    // Each entry contains the conditions and the nesting level where they were introduced
63182
    let mut at_rule_stack: Vec<(Vec<DynamicSelector>, usize)> = Vec::new();
    // Pending @-rule that needs to be combined with AtStr tokens
63182
    let mut pending_at_rule: Option<&str> = None;
    // Collect multiple AtStr tokens (e.g., "screen", "(min-width: 800px)" for compound media queries)
63182
    let mut pending_at_str_parts: Vec<String> = Vec::new();
63182
    let mut keyframes_capture: Option<KfCapture> = None;
    // Stack for nested selectors
    // Each entry: (parent_paths, declarations, nesting_level)
    // parent_paths: all accumulated paths at this level (for comma-separated selectors)
    // declarations: current declarations at this level
63182
    let mut nesting_stack: Vec<NestingLevel<'a>> = Vec::new();
    // Current accumulated paths before BlockStart
63182
    let mut current_paths: Vec<Vec<CssPathSelector>> = Vec::new();
    // Current declarations at current level
63182
    let mut current_declarations: BTreeMap<&str, (&str, ErrorLocationRange)> = BTreeMap::new();
    // Safety: limit maximum iterations to prevent infinite loops
    // A reasonable limit is 10x the input length (each char could produce at most a few tokens)
63182
    let max_iterations = css_string.len().saturating_mul(10).max(1000);
63182
    let mut iterations = 0_usize;
63182
    let mut last_position = 0_usize;
63182
    let mut stuck_count = 0_usize;
    loop {
        // Safety check 1: Maximum iterations
1052873
        iterations += 1;
1052873
        if iterations > max_iterations {
            warnings.push(CssParseWarnMsg {
                warning: CssParseWarnMsgInner::MalformedStructure {
                    message: "Parser iteration limit exceeded - possible infinite loop",
                },
                location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
            });
            break;
1052873
        }
        // Safety check 2: Detect if parser is stuck (position not advancing)
1052873
        let current_position = tokenizer.pos();
1052873
        if current_position == last_position {
63182
            stuck_count += 1;
63182
            if stuck_count > 10 {
                warnings.push(CssParseWarnMsg {
                    warning: CssParseWarnMsgInner::MalformedStructure {
                        message: "Parser stuck - position not advancing",
                    },
                    location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
                });
                break;
63182
            }
989691
        } else {
989691
            stuck_count = 0;
989691
            last_position = current_position;
989691
        }
1052873
        let token = match tokenizer.parse_next() {
1052374
            Ok(token) => token,
499
            Err(e) => {
499
                let error_location = get_error_location(tokenizer);
                // An unclosed block that still contains a declaration makes the
                // tokenizer raise UnexpectedEndOfStream while scanning past the last `;`
                // for the missing `}`, BEFORE the loop ever reaches Token::EndOfStream.
                // Emit the same dedicated "unclosed blocks" diagnostic that arm would,
                // rather than a generic parse error, when we're still inside a block.
499
                let warning = if block_nesting != 0 {
274
                    CssParseWarnMsgInner::MalformedStructure {
274
                        message: "Unclosed blocks at end of file",
274
                    }
                } else {
225
                    CssParseWarnMsgInner::ParseError(e.into())
                };
499
                warnings.push(CssParseWarnMsg {
499
                    warning,
499
                    location: ErrorLocationRange { start: last_error_location, end: error_location },
499
                });
                // On error, break to avoid infinite loop - the tokenizer may be stuck
499
                break;
            }
        };
        macro_rules! warn_and_continue {
            ($warning:expr) => {{
                warnings.push(CssParseWarnMsg {
                    warning: $warning,
                    location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
                });
                continue;
            }};
        }
1052374
        if keyframes_capture.is_some() {
3599
            let mut close_kf = false;
3599
            let mut eos = false;
            {
3599
                let cap = keyframes_capture
3599
                    .as_mut()
3599
                    .expect("checked is_some above");
3599
                match token {
800
                    Token::TypeSelector(sel) => {
800
                        if !cap.in_stop {
800
                            cap.selectors.push(sel.to_string());
800
                        }
                    }
799
                    Token::BlockStart => {
799
                        block_nesting += 1;
799
                        cap.in_stop = true;
799
                        cap.props.clear();
799
                    }
801
                    Token::Declaration(key, val) => {
801
                        if cap.in_stop {
801
                            let key_map = crate::props::property::get_css_key_map();
801
                            if let Some(ty) = CssPropertyType::from_str(key.trim(), &key_map) {
801
                                if let Ok(prop) = parse_css_property(ty, val.trim()) {
801
                                    cap.props.push(prop);
801
                                }
                            }
                        }
                    }
                    Token::BlockEnd => {
1198
                        block_nesting = block_nesting.saturating_sub(1);
1198
                        if cap.in_stop {
799
                            cap.in_stop = false;
                            // One stop per comma-listed selector; they share
                            // the declaration set. Unknown selectors skip.
799
                            let props = core::mem::take(&mut cap.props);
800
                            for sel in cap.selectors.drain(..) {
800
                                if let Some(permille) = stop_selector_permille(&sel) {
800
                                    cap.stops.push(crate::css::KeyframeStop {
800
                                        permille,
800
                                        props: props.clone().into(),
800
                                    });
800
                                }
                            }
399
                        } else {
399
                            close_kf = true;
399
                        }
                    }
                    Token::EndOfStream => {
                        eos = true;
                    }
                    // Comma between stop selectors needs no action (they
                    // accumulate); anything else unsupported is skipped.
1
                    _ => {}
                }
            }
3599
            if close_kf {
399
                let mut cap = keyframes_capture
399
                    .take()
399
                    .expect("close_kf implies capture");
399
                cap.stops.sort_by_key(|st| st.permille);
399
                keyframes_out.push(crate::css::Keyframes {
399
                    name: cap.name.into(),
399
                    stops: cap.stops.into(),
399
                });
3200
            }
3599
            if eos {
                warnings.push(CssParseWarnMsg {
                    warning: CssParseWarnMsgInner::MalformedStructure {
                        message: "Unclosed blocks at end of file",
                    },
                    location: ErrorLocationRange {
                        start: last_error_location,
                        end: get_error_location(tokenizer),
                    },
                });
                break;
3599
            }
3599
            last_error_location = get_error_location(tokenizer);
3599
            continue;
1048775
        }
10812
        match token {
909
            Token::AtRule(rule_name) => {
909
                // Store the @-rule name to combine with the following AtStr tokens
909
                pending_at_rule = Some(rule_name);
909
                pending_at_str_parts.clear();
909
            }
873
            Token::AtStr(content) => {
                // Collect AtStr tokens until we see BlockStart
873
                if pending_at_rule.is_some() {
                    // Skip "and" keyword, it's just a separator
873
                    if !content.eq_ignore_ascii_case("and") {
859
                        pending_at_str_parts.push(content.to_string());
859
                    }
                }
            }
            Token::BlockStart => {
                // `@keyframes <name> {` switches into stop-collection mode —
                // no selector machinery, no condition stack (an ENCLOSING
                // @media's conditions do not gate keyframes yet; they parse
                // and join the flat list).
128615
                if pending_at_rule.is_some_and(|r| r.eq_ignore_ascii_case("keyframes")) {
399
                    pending_at_rule = None;
399
                    let name = pending_at_str_parts.join(" ");
399
                    pending_at_str_parts.clear();
399
                    block_nesting += 1;
399
                    keyframes_capture = Some(KfCapture {
399
                        name,
399
                        stops: Vec::new(),
399
                        selectors: Vec::new(),
399
                        props: Vec::new(),
399
                        in_stop: false,
399
                    });
399
                    last_error_location = get_error_location(tokenizer);
399
                    continue;
128216
                }
                // Process pending @-rule with all collected AtStr parts
128216
                if let Some(rule_name) = pending_at_rule.take() {
489
                    let combined_content = pending_at_str_parts.join(" and ");
489
                    pending_at_str_parts.clear();
489
                    let conditions = match rule_name.to_lowercase().as_str() {
489
                        "media" => parse_media_conditions(&combined_content),
249
                        "lang" => parse_lang_condition(&combined_content).into_iter().collect(),
248
                        "os" => crate::dynamic_selector::parse_os_at_rule_content(&combined_content).unwrap_or_default(),
3
                        "theme" => parse_theme_condition(&combined_content).into_iter().collect(),
2
                        "container" => parse_container_conditions(&combined_content),
                        _ => {
                            // Unknown @-rule, ignore
1
                            Vec::new()
                        }
                    };
489
                    if !conditions.is_empty() {
444
                        // Push conditions to stack, will be applied to nested rules
444
                        at_rule_stack.push((conditions, block_nesting + 1));
486
                    }
127727
                }
128216
                block_nesting += 1;
                // If we have a selector, push current state onto nesting stack
128216
                if !current_paths.is_empty() || !last_path.is_empty() {
                    // Finalize current_paths with last_path
122675
                    if !last_path.is_empty() {
122674
                        current_paths.push(last_path.clone());
122674
                        last_path.clear();
122674
                    }
                    // Get parent paths and combine with current paths. Beyond
                    // MAX_NESTING_DEPTH, stop combining with the ancestor chain to
                    // bound the O(depth^2) path-cloning (see the const's doc above).
122675
                    let combined_paths: Vec<Vec<CssPathSelector>> = if block_nesting > MAX_NESTING_DEPTH {
8976
                        std::mem::take(&mut current_paths)
                    } else {
113699
                        let parent_paths = get_parent_paths(&nesting_stack);
113699
                        if parent_paths.is_empty() {
95785
                            current_paths.clone()
                        } else {
                            // Combine each parent path with each current path
17914
                            let mut result = Vec::new();
35828
                            for parent in &parent_paths {
35835
                                for child in &current_paths {
                                    // Check if child starts with pseudo-selector
17921
                                    let is_pseudo_only = child.first().is_some_and(|s| matches!(s, CssPathSelector::PseudoSelector(_)));
17921
                                    let mut combined = parent.clone();
17921
                                    if !is_pseudo_only && !child.is_empty() {
1586
                                        combined.push(CssPathSelector::Children);
17877
                                    }
17921
                                    combined.extend(child.iter().cloned());
17921
                                    result.push(combined);
                                }
                            }
17914
                            result
                        }
                    };
                    // Push to nesting stack
122675
                    nesting_stack.push(NestingLevel {
122675
                        paths: combined_paths,
122675
                        declarations: std::mem::take(&mut current_declarations),
122675
                        depth: block_nesting,
122675
                    });
122675
                    current_paths.clear();
5541
                }
            }
            Token::Comma => {
                // Comma separates selectors
1473
                if !last_path.is_empty() {
1472
                    current_paths.push(last_path.clone());
1472
                    last_path.clear();
1472
                }
            }
            Token::BlockEnd => {
122817
                if block_nesting == 0 {
                    warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
                        message: "Block end without matching block start"
                    });
122817
                }
                // Collect all conditions from the current @-rule stack
122817
                let current_conditions: Vec<DynamicSelector> = at_rule_stack
122817
                    .iter()
122817
                    .flat_map(|(conds, _)| conds.iter().cloned())
122817
                    .collect();
                // Pop @-rule conditions that are at this nesting level
123261
                while let Some((_, level)) = at_rule_stack.last() {
931
                    if *level >= block_nesting {
444
                        at_rule_stack.pop();
444
                    } else {
487
                        break;
                    }
                }
122817
                block_nesting = block_nesting.saturating_sub(1);
                // Pop from nesting stack if we have one
122817
                if let Some(level) = nesting_stack.pop() {
                    // Emit CSS blocks for all paths at this level
122357
                    if !level.paths.is_empty() && !current_declarations.is_empty() {
111534
                        css_blocks.extend(level.paths.iter().map(|path| UnparsedCssRuleBlock {
113005
                            path: CssPath {
113005
                                selectors: path.clone().into(),
113005
                            },
113005
                            declarations: current_declarations.clone(),
113005
                            conditions: current_conditions.clone(),
113005
                        }));
10823
                    }
                    // Restore parent declarations
122357
                    current_declarations = level.declarations;
460
                }
122817
                last_path.clear();
122817
                current_paths.clear();
            }
45424
            Token::UniversalSelector => {
45424
                last_path.push(CssPathSelector::Global);
45424
            }
46099
            Token::TypeSelector(div_type) => {
46099
                match NodeTypeTag::from_str(div_type) {
46024
                    Ok(nt) => last_path.push(CssPathSelector::Type(nt)),
75
                    Err(e) => {
75
                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
75
                            selector: Some(div_type),
75
                            error: e.into(),
75
                        });
                    }
                }
            }
4125
            Token::IdSelector(id) => {
4125
                last_path.push(CssPathSelector::Id(id.to_string().into()));
4125
            }
23123
            Token::ClassSelector(class) => {
23123
                last_path.push(CssPathSelector::Class(class.to_string().into()));
23123
            }
40
            Token::Combinator(Combinator::GreaterThan) => {
40
                last_path.push(CssPathSelector::DirectChildren);
40
            }
10758
            Token::Combinator(Combinator::Space) => {
10758
                last_path.push(CssPathSelector::Children);
10758
            }
7
            Token::Combinator(Combinator::Plus) => {
7
                last_path.push(CssPathSelector::AdjacentSibling);
7
            }
7
            Token::Combinator(Combinator::Tilde) => {
7
                last_path.push(CssPathSelector::GeneralSibling);
7
            }
16512
            Token::PseudoClass { selector, value } | Token::DoublePseudoClass { selector, value } => {
16540
                match pseudo_selector_from_str(selector, value) {
16504
                    Ok(ps) => last_path.push(CssPathSelector::PseudoSelector(ps)),
36
                    Err(e) => {
36
                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
36
                            selector: Some(selector),
36
                            error: e.into(),
36
                        });
                    }
                }
            }
64
            Token::AttributeSelector(attr) => {
64
                if let Some(sel) = parse_attribute_selector(attr) { last_path.push(CssPathSelector::Attribute(sel)) } else { warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
                    message: "Malformed attribute selector, rule skipped",
                }) }
            }
585218
            Token::Declaration(key, val) => {
585218
                current_declarations.insert(
585218
                    key,
585218
                    (val, ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) }),
585218
                );
585218
            }
            Token::EndOfStream => {
62683
                if block_nesting != 0 {
114
                    warnings.push(CssParseWarnMsg {
114
                        warning: CssParseWarnMsgInner::MalformedStructure {
114
                            message: "Unclosed blocks at end of file",
114
                        },
114
                        location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
114
                    });
62569
                }
62683
                break;
            }
            _ => { /* Ignore unsupported tokens */ }
        }
985582
        last_error_location = get_error_location(tokenizer);
    }
    // Process the collected CSS blocks and convert warnings
63182
    let (stylesheet, mut block_warnings) = css_blocks_to_stylesheet(css_blocks, css_string);
63182
    warnings.append(&mut block_warnings);
63182
    (stylesheet, warnings)
63182
}
/// Resolves a parsed `var(--name)` reference (a `CssDeclaration::Dynamic`) against the
/// document-wide custom-property map, producing a concrete `Static` declaration.
///
/// If the referenced custom property is defined, its raw value is parsed as the referenced
/// property's type (taken from the `var()`'s parsed fallback). Otherwise — undefined `var()`
/// or an unparseable value — the fallback (`default_value`) is used, matching the CSS
/// behaviour of an invalid/guaranteed-invalid substitution falling back to the declared
/// default. Non-`Dynamic` declarations pass through unchanged.
510706
fn resolve_var_reference(
510706
    decl: CssDeclaration,
510706
    custom_props: &BTreeMap<String, String>,
510706
) -> CssDeclaration {
510706
    let CssDeclaration::Dynamic(dyn_prop) = decl else {
510704
        return decl;
    };
    // `dynamic_id` is stored without the leading `--`; trim defensively either way.
2
    let name = dyn_prop.dynamic_id.as_str().trim_start_matches("--");
2
    if let Some(raw) = custom_props.get(name) {
1
        if let Ok(parsed) = parse_css_property(dyn_prop.default_value.get_type(), raw) {
1
            return CssDeclaration::Static(parsed);
        }
1
    }
1
    CssDeclaration::Static(dyn_prop.default_value)
510706
}
63184
fn css_blocks_to_stylesheet<'a>(
63184
    css_blocks: Vec<UnparsedCssRuleBlock<'a>>,
63184
    css_string: &'a str,
63184
) -> (Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>) {
63184
    let css_key_map = crate::props::property::get_css_key_map();
63184
    let mut warnings = Vec::new();
63184
    let mut parsed_css_blocks = Vec::new();
    // CSS custom properties (`--name: value`) + `var()` references. The parser already turns
    // `prop: var(--name, fallback)` into a `CssDeclaration::Dynamic`, but nothing consumed it
    // and `--name` definitions were dropped as unknown keys. Resolve them here at parse time:
    // collect every `--name` definition document-wide, then substitute each var() reference
    // with the referenced value (parsed as the target property's type) or its fallback. This
    // is a pragmatic subset of the full cascade — it covers the common `:root { --x: ... }`
    // pattern; element-scoped custom properties (redefined per subtree) are not modelled, which
    // would require cascade-level storage. Keys are stored without the leading `--`.
63184
    let mut custom_props: BTreeMap<String, String> = BTreeMap::new();
176191
    for block in &css_blocks {
502806
        for (key, (value, _)) in &block.declarations {
389799
            if let Some(name) = key.strip_prefix("--") {
1
                custom_props.insert(name.to_string(), value.trim().to_string());
389798
            }
        }
    }
176191
    for unparsed_css_block in css_blocks {
113007
        let mut declarations = Vec::<CssDeclaration>::new();
502806
        for (unparsed_css_key, (unparsed_css_value, location)) in &unparsed_css_block.declarations {
            // Custom-property DEFINITIONS were collected above; they emit no declaration
            // themselves (and must not warn as unknown keys).
389799
            if unparsed_css_key.starts_with("--") {
1
                continue;
389798
            }
389798
            match parse_declaration_resilient(
389798
                unparsed_css_key,
389798
                unparsed_css_value,
389798
                *location,
389798
                &css_key_map,
389798
            ) {
356408
                Ok(decls) => {
510706
                    declarations.extend(decls.into_iter().map(|d| resolve_var_reference(d, &custom_props)));
                }
33390
                Err(e) => {
33390
                    warnings.push(CssParseWarnMsg {
33390
                        warning: CssParseWarnMsgInner::SkippedDeclaration {
33390
                            key: unparsed_css_key,
33390
                            value: unparsed_css_value,
33390
                            error: e,
33390
                        },
33390
                        location: *location,
33390
                    });
33390
                }
            }
        }
113007
        parsed_css_blocks.push(CssRuleBlock {
113007
            path: unparsed_css_block.path,
113007
            declarations: declarations.into(),
113007
            conditions: unparsed_css_block.conditions.into(),
113007
            priority: crate::css::rule_priority::AUTHOR,
113007
        });
    }
63184
    (parsed_css_blocks, warnings)
63184
}
414933
fn parse_declaration_resilient<'a>(
414933
    unparsed_css_key: &'a str,
414933
    unparsed_css_value: &'a str,
414933
    location: ErrorLocationRange,
414933
    css_key_map: &CssKeyMap,
414933
) -> Result<Vec<CssDeclaration>, CssParseErrorInner<'a>> {
414933
    let mut declarations = Vec::new();
414933
    if let Some(combined_key) = CombinedCssPropertyType::from_str(unparsed_css_key, css_key_map) {
59550
        if check_if_value_is_css_var(unparsed_css_value).is_some() {
1
            return Err(CssParseErrorInner::VarOnShorthandProperty {
1
                key: combined_key,
1
                value: unparsed_css_value,
1
            });
59549
        }
        // Attempt to parse combined properties, continue with what succeeds
59549
        match parse_combined_css_property(combined_key, unparsed_css_value) {
59507
            Ok(parsed_props) => {
59507
                declarations.extend(parsed_props.into_iter().map(CssDeclaration::Static));
59507
            }
42
            Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
        }
355383
    } else if let Some(normal_key) = CssPropertyType::from_str(unparsed_css_key, css_key_map) {
330260
        if let Some(css_var) = check_if_value_is_css_var(unparsed_css_value) {
3
            let (css_var_id, css_var_default) = css_var?;
3
            match parse_css_property(normal_key, css_var_default) {
3
                Ok(parsed_default) => {
3
                    declarations.push(CssDeclaration::Dynamic(DynamicCssProperty {
3
                        dynamic_id: css_var_id.to_string().into(),
3
                        default_value: parsed_default,
3
                    }));
3
                }
                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
            }
        } else {
330257
            match parse_css_property(normal_key, unparsed_css_value) {
296936
                Ok(parsed_value) => {
296936
                    declarations.push(CssDeclaration::Static(parsed_value));
296936
                }
33321
                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
            }
        }
    } else {
25123
        return Err(CssParseErrorInner::UnknownPropertyKey(
25123
            unparsed_css_key,
25123
            unparsed_css_value,
25123
        ));
    }
356446
    Ok(declarations)
414933
}
/// Parses a single CSS key-value declaration, appending results to `declarations`.
///
/// Unknown property keys are downgraded to warnings (pushed into `warnings`)
/// rather than causing a hard error, so callers can continue processing the
/// remaining declarations in a rule block.
/// # Errors
///
/// Returns an error if `input` is not a valid CSS `css-declaration` value.
24171
pub fn parse_css_declaration<'a>(
24171
    unparsed_css_key: &'a str,
24171
    unparsed_css_value: &'a str,
24171
    location: ErrorLocationRange,
24171
    css_key_map: &CssKeyMap,
24171
    warnings: &mut Vec<CssParseWarnMsg<'a>>,
24171
    declarations: &mut Vec<CssDeclaration>,
24171
) -> Result<(), CssParseErrorInner<'a>> {
24171
    match parse_declaration_resilient(unparsed_css_key, unparsed_css_value, location, css_key_map) {
37
        Ok(mut decls) => {
37
            declarations.append(&mut decls);
37
            Ok(())
        }
24134
        Err(e) => {
24134
            if let CssParseErrorInner::UnknownPropertyKey(key, val) = &e {
24097
                warnings.push(CssParseWarnMsg {
24097
                    warning: CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value: val },
24097
                    location,
24097
                });
24097
                Ok(()) // Continue processing despite unknown property
            } else {
37
                Err(e) // Propagate other errors
            }
        }
    }
24171
}
389852
fn check_if_value_is_css_var(
389852
    unparsed_css_value: &str,
389852
) -> Option<Result<(&str, &str), CssParseErrorInner<'_>>> {
    const DEFAULT_VARIABLE_DEFAULT: &str = "none";
389852
    let (_, brace_contents) = parse_parentheses(unparsed_css_value, &["var"]).ok()?;
    // value is a CSS variable, i.e. var(--main-bg-color)
11
    Some(match parse_css_variable_brace_contents(brace_contents) {
7
        Some((variable_id, default_value)) => Ok((
7
            variable_id,
7
            default_value.unwrap_or(DEFAULT_VARIABLE_DEFAULT),
7
        )),
4
        None => Err(DynamicCssParseError::InvalidBraceContents(brace_contents).into()),
    })
389852
}
/// Parses the brace contents of a css var, i.e.:
///
/// ```no_run,ignore
/// "--main-bg-col, blue" => (Some("main-bg-col"), Some("blue"))
/// "--main-bg-col"       => (Some("main-bg-col"), None)
/// ```
22
fn parse_css_variable_brace_contents(input: &str) -> Option<(&str, Option<&str>)> {
22
    let input = input.trim();
22
    let mut split_comma_iter = input.splitn(2, ',');
22
    let var_name = split_comma_iter.next()?;
22
    let var_name = var_name.trim();
22
    if !var_name.starts_with("--") {
11
        return None; // no proper CSS variable name
11
    }
11
    Some((&var_name[2..], split_comma_iter.next()))
22
}
#[cfg(test)]
#[allow(
    clippy::all,
    clippy::pedantic,
    clippy::nursery,
    unused_qualifications,
    single_use_lifetimes
)]
mod autotest_generated {
    use super::*;
    use crate::css::CssNthChildPattern;
    // ---------------------------------------------------------------------
    // helpers
    // ---------------------------------------------------------------------
    /// Runs `f`, converting a panic into `Err(message)` so that a *panicking*
    /// function under test produces a readable assertion failure instead of
    /// tearing down the test binary. `[profile.test] panic = "unwind"` is set
    /// in the workspace root `Cargo.toml`, so unwinding is available here.
    fn catch<R>(f: impl FnOnce() -> R) -> Result<R, String> {
        std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|e| {
            e.downcast_ref::<String>().cloned().unwrap_or_else(|| {
                e.downcast_ref::<&str>()
                    .map_or_else(|| "<non-string panic payload>".to_string(), |s| (*s).to_string())
            })
        })
    }
    fn key_map() -> CssKeyMap {
        crate::props::property::get_css_key_map()
    }
    fn loc(start: usize, end: usize) -> ErrorLocationRange {
        ErrorLocationRange {
            start: ErrorLocation { original_pos: start },
            end: ErrorLocation { original_pos: end },
        }
    }
    /// A grab-bag of hostile inputs reused across the string parsers.
    const HOSTILE: &[&str] = &[
        "",
        " ",
        "   \t\n\r  ",
        "\0",
        "\u{1F600}",
        "e\u{301}\u{301}\u{301}",
        "-0",
        "0",
        "NaN",
        "inf",
        "-inf",
        "9223372036854775807",
        "-9223372036854775808",
        "18446744073709551616",
        "1e309",
        ";",
        "{}",
        "()",
        "((((",
        "))))",
        "\"",
        "'",
        "\\",
        "//",
        "/*",
        "valid;garbage",
        "  valid  ",
        "a=b=c",
        ":::",
        "--",
    ];
    // =====================================================================
    // parsers -> malformed / huge / boundary / unicode
    // =====================================================================
    // --- pseudo_selector_from_str ----------------------------------------
    #[test]
    fn pseudo_selector_from_str_valid_minimal() {
        assert_eq!(
            pseudo_selector_from_str("hover", None),
            Ok(CssPathPseudoSelector::Hover)
        );
        assert_eq!(
            pseudo_selector_from_str("first", None),
            Ok(CssPathPseudoSelector::First)
        );
        assert_eq!(
            pseudo_selector_from_str("last", None),
            Ok(CssPathPseudoSelector::Last)
        );
        assert_eq!(
            pseudo_selector_from_str("active", None),
            Ok(CssPathPseudoSelector::Active)
        );
        assert_eq!(
            pseudo_selector_from_str("focus", None),
            Ok(CssPathPseudoSelector::Focus)
        );
        assert_eq!(
            pseudo_selector_from_str("dragging", None),
            Ok(CssPathPseudoSelector::Dragging)
        );
        assert_eq!(
            pseudo_selector_from_str("drag-over", None),
            Ok(CssPathPseudoSelector::DragOver)
        );
        assert_eq!(
            pseudo_selector_from_str("root", None),
            Ok(CssPathPseudoSelector::Root)
        );
    }
    #[test]
    fn pseudo_selector_from_str_nth_child_needs_a_value() {
        assert_eq!(
            pseudo_selector_from_str("nth-child", None),
            Err(CssPseudoSelectorParseError::EmptyNthChild)
        );
        assert_eq!(
            pseudo_selector_from_str("nth-child", Some("2")),
            Ok(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(2)))
        );
    }
    #[test]
    fn pseudo_selector_from_str_lang_strips_quotes() {
        // Both quote styles are stripped, and the inner value is trimmed.
        for v in ["de-DE", "\"de-DE\"", "'de-DE'", "  \"de-DE\"  "] {
            assert_eq!(
                pseudo_selector_from_str("lang", Some(v)),
                Ok(CssPathPseudoSelector::Lang(AzString::from("de-DE".to_string()))),
                "lang value {v:?} did not normalise to de-DE"
            );
        }
        // A `:lang` with no value is rejected rather than defaulting to "".
        assert!(pseudo_selector_from_str("lang", None).is_err());
    }
    #[test]
    fn pseudo_selector_from_str_empty_and_whitespace_are_rejected() {
        assert!(pseudo_selector_from_str("", None).is_err());
        assert!(pseudo_selector_from_str("   ", None).is_err());
        assert!(pseudo_selector_from_str("\t\n", None).is_err());
        // The selector name is matched verbatim, so a padded name is *not* accepted.
        assert!(pseudo_selector_from_str(" hover ", None).is_err());
    }
    #[test]
    fn pseudo_selector_from_str_garbage_and_unicode_never_panic() {
        for s in HOSTILE {
            for v in [None, Some(*s), Some("2"), Some("\u{1F600}")] {
                let r = catch(|| pseudo_selector_from_str(s, v));
                assert!(
                    r.is_ok(),
                    "pseudo_selector_from_str({s:?}, {v:?}) panicked: {}",
                    r.unwrap_err()
                );
                // Nothing in HOSTILE is a real pseudo-selector name.
                assert!(
                    pseudo_selector_from_str(s, v).is_err(),
                    "pseudo_selector_from_str({s:?}, {v:?}) unexpectedly succeeded"
                );
            }
        }
    }
    #[test]
    fn pseudo_selector_from_str_extremely_long_input_terminates() {
        let long = "z".repeat(200_000);
        assert!(pseudo_selector_from_str(&long, None).is_err());
        // A huge *value* on a selector that ignores values must also terminate.
        assert_eq!(
            pseudo_selector_from_str("hover", Some(&long)),
            Ok(CssPathPseudoSelector::Hover)
        );
        // A huge nth-child value is rejected, not parsed into a bogus number.
        assert!(pseudo_selector_from_str("nth-child", Some(&long)).is_err());
    }
    #[test]
    fn pseudo_selector_from_str_deeply_nested_value_does_not_stack_overflow() {
        let nested = "(".repeat(10_000);
        let r = catch(|| pseudo_selector_from_str("nth-child", Some(&nested)).is_err());
        assert_eq!(r, Ok(true), "deeply nested nth-child value was not rejected safely");
    }
    // --- parse_attribute_selector ----------------------------------------
    #[test]
    fn parse_attribute_selector_valid_minimal() {
        let sel = parse_attribute_selector("href").expect("bare attribute name must parse");
        assert_eq!(sel.name.as_str(), "href");
        assert_eq!(sel.op, AttributeMatchOp::Exists);
        assert_eq!(sel.value.clone().into_option(), None);
    }
    #[test]
    fn parse_attribute_selector_all_operators() {
        let cases: [(&str, AttributeMatchOp); 6] = [
            ("a=b", AttributeMatchOp::Eq),
            ("a~=b", AttributeMatchOp::Includes),
            ("a|=b", AttributeMatchOp::DashMatch),
            ("a^=b", AttributeMatchOp::Prefix),
            ("a$=b", AttributeMatchOp::Suffix),
            ("a*=b", AttributeMatchOp::Substring),
        ];
        for (input, expected_op) in cases {
            let sel = parse_attribute_selector(input)
                .unwrap_or_else(|| panic!("{input:?} should parse"));
            assert_eq!(sel.name.as_str(), "a", "wrong name for {input:?}");
            assert_eq!(sel.op, expected_op, "wrong op for {input:?}");
            assert_eq!(
                sel.value.clone().into_option().map(|v| v.as_str().to_string()),
                Some("b".to_string()),
                "wrong value for {input:?}"
            );
        }
    }
    #[test]
    fn parse_attribute_selector_quotes_are_stripped_and_unbalanced_rejected() {
        for input in ["a=\"b\"", "a='b'", "a=b", "  a  =  \"b\"  "] {
            let sel = parse_attribute_selector(input)
                .unwrap_or_else(|| panic!("{input:?} should parse"));
            assert_eq!(
                sel.value.clone().into_option().map(|v| v.as_str().to_string()),
                Some("b".to_string()),
                "quotes not stripped for {input:?}"
            );
        }
        // Unbalanced quoting is a hard reject, not a silent half-strip.
        for input in ["a=\"b", "a=b\"", "a='b", "a=b'", "a=\"b'", "a=\"", "a='"] {
            assert!(
                parse_attribute_selector(input).is_none(),
                "unbalanced quote {input:?} should be rejected"
            );
        }
    }
    #[test]
    fn parse_attribute_selector_empty_and_malformed_are_rejected() {
        for input in ["", "   ", "\t\n", "=", "=b", "  =b", "\"a\"", "'a'"] {
            assert!(
                parse_attribute_selector(input).is_none(),
                "{input:?} should be rejected (empty/quoted name)"
            );
        }
        // Names may not contain whitespace.
        assert!(parse_attribute_selector("a b").is_none());
        assert!(parse_attribute_selector("a b=c").is_none());
    }
    #[test]
    fn parse_attribute_selector_unicode_name_is_accepted_and_does_not_panic() {
        let sel = parse_attribute_selector("data-\u{1F600}")
            .expect("a non-ASCII attribute name has no whitespace/quotes, so it is accepted");
        assert_eq!(sel.name.as_str(), "data-\u{1F600}");
        // Multi-byte values must not be sliced mid-char.
        let sel = parse_attribute_selector("lang=\"\u{4E2D}\u{6587}\"").expect("unicode value");
        assert_eq!(
            sel.value.clone().into_option().map(|v| v.as_str().to_string()),
            Some("\u{4E2D}\u{6587}".to_string())
        );
    }
    /// Invariant: whatever comes back, the name is never empty and never contains
    /// whitespace or quotes -- those are exactly the cases the parser promises to
    /// reject. This holds regardless of how the operator split is implemented.
    #[test]
    fn parse_attribute_selector_result_invariants_hold_for_hostile_input() {
        let long = format!("a={}", "x".repeat(100_000));
        let nested = format!("a={}", "[".repeat(10_000));
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long);
        inputs.push(&nested);
        inputs.push("title=\"a~=b\"");
        inputs.push("a=b=c");
        inputs.push("[[[[]]]]");
        for input in inputs {
            let parsed = match catch(|| parse_attribute_selector(input)) {
                Ok(p) => p,
                Err(msg) => panic!("parse_attribute_selector({input:?}) panicked: {msg}"),
            };
            if let Some(sel) = parsed {
                let name = sel.name.as_str();
                assert!(!name.is_empty(), "empty name accepted for {input:?}");
                assert!(
                    !name.chars().any(|c| c.is_whitespace() || c == '"' || c == '\''),
                    "name {name:?} contains whitespace/quotes for input {input:?}"
                );
            }
        }
    }
    // --- strip_attribute_quotes (private) --------------------------------
    #[test]
    fn strip_attribute_quotes_balanced_unquoted_and_unbalanced() {
        // Balanced -> stripped.
        assert_eq!(strip_attribute_quotes("\"abc\""), Some("abc"));
        assert_eq!(strip_attribute_quotes("'abc'"), Some("abc"));
        assert_eq!(strip_attribute_quotes("\"\""), Some(""));
        assert_eq!(strip_attribute_quotes("''"), Some(""));
        // Unquoted -> unchanged.
        assert_eq!(strip_attribute_quotes("abc"), Some("abc"));
        assert_eq!(strip_attribute_quotes(""), Some(""));
        assert_eq!(strip_attribute_quotes("a"), Some("a"));
        // Unbalanced -> None.
        assert_eq!(strip_attribute_quotes("\"abc"), None);
        assert_eq!(strip_attribute_quotes("abc\""), None);
        assert_eq!(strip_attribute_quotes("'abc"), None);
        assert_eq!(strip_attribute_quotes("abc'"), None);
        assert_eq!(strip_attribute_quotes("\"abc'"), None);
        assert_eq!(strip_attribute_quotes("\""), None);
        assert_eq!(strip_attribute_quotes("'"), None);
    }
    /// The function slices with raw byte indices (`&s[1..s.len() - 1]`), so a
    /// multi-byte first/last char is the interesting boundary case. No byte of a
    /// multi-byte UTF-8 sequence can equal `"` (0x22) or `'` (0x27), so the slice
    /// must always land on a char boundary.
    #[test]
    fn strip_attribute_quotes_multibyte_boundaries_never_panic() {
        let cases = [
            "\u{1F600}",
            "\"\u{1F600}\"",
            "'\u{4E2D}\u{6587}'",
            "\u{4E2D}\u{6587}",
            "\"\u{301}\"",
            "\u{301}",
        ];
        for s in cases {
            let r = catch(|| strip_attribute_quotes(s));
            assert!(r.is_ok(), "strip_attribute_quotes({s:?}) panicked: {}", r.unwrap_err());
        }
        assert_eq!(strip_attribute_quotes("\"\u{1F600}\""), Some("\u{1F600}"));
        assert_eq!(strip_attribute_quotes("\u{1F600}"), Some("\u{1F600}"));
    }
    #[test]
    fn strip_attribute_quotes_extremely_long_input_terminates() {
        let long = "x".repeat(500_000);
        assert_eq!(strip_attribute_quotes(&long), Some(long.as_str()));
        let quoted = format!("\"{long}\"");
        assert_eq!(strip_attribute_quotes(&quoted), Some(long.as_str()));
    }
    // --- parse_nth_child_selector / parse_nth_child_pattern (private) -----
    #[test]
    fn parse_nth_child_selector_valid_minimal() {
        assert_eq!(parse_nth_child_selector("2"), Ok(CssNthChildSelector::Number(2)));
        assert_eq!(parse_nth_child_selector("0"), Ok(CssNthChildSelector::Number(0)));
        assert_eq!(parse_nth_child_selector("even"), Ok(CssNthChildSelector::Even));
        assert_eq!(parse_nth_child_selector("odd"), Ok(CssNthChildSelector::Odd));
        assert_eq!(parse_nth_child_selector("  7  "), Ok(CssNthChildSelector::Number(7)));
        assert_eq!(
            parse_nth_child_selector("2n+3"),
            Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
                pattern_repeat: 2,
                offset: 3
            }))
        );
        assert_eq!(
            parse_nth_child_selector("2n"),
            Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
                pattern_repeat: 2,
                offset: 0
            }))
        );
    }
    #[test]
    fn parse_nth_child_selector_empty_is_empty_nth_child_error() {
        assert_eq!(
            parse_nth_child_selector(""),
            Err(CssPseudoSelectorParseError::EmptyNthChild)
        );
        assert_eq!(
            parse_nth_child_selector("   \t\n "),
            Err(CssPseudoSelectorParseError::EmptyNthChild)
        );
        assert_eq!(
            parse_nth_child_pattern(""),
            Err(CssPseudoSelectorParseError::EmptyNthChild)
        );
    }
    /// `u32` boundaries: MAX parses, MAX+1 and negatives are rejected via
    /// `ParseIntError` rather than wrapping or panicking.
    #[test]
    fn parse_nth_child_selector_numeric_limits_saturate_into_errors() {
        assert_eq!(
            parse_nth_child_selector("4294967295"),
            Ok(CssNthChildSelector::Number(u32::MAX))
        );
        for overflow in [
            "4294967296",
            "18446744073709551616",
            "99999999999999999999999999",
            "-1",
            "-0",
        ] {
            assert!(
                parse_nth_child_selector(overflow).is_err(),
                "{overflow:?} must not parse as an nth-child index"
            );
        }
        // Huge digit runs must be rejected, not truncated -- and must terminate.
        let huge = "9".repeat(100_000);
        assert!(parse_nth_child_selector(&huge).is_err());
        let huge_repeat = format!("{}n+1", "9".repeat(100_000));
        assert!(parse_nth_child_pattern(&huge_repeat).is_err());
    }
    #[test]
    fn parse_nth_child_selector_float_and_non_finite_strings_are_rejected() {
        for v in ["NaN", "inf", "-inf", "1.5", "1e5", "0x2", "+2", " 2 n "] {
            let r = catch(|| parse_nth_child_selector(v));
            assert!(r.is_ok(), "parse_nth_child_selector({v:?}) panicked: {}", r.unwrap_err());
        }
        assert!(parse_nth_child_selector("NaN").is_err());
        assert!(parse_nth_child_selector("inf").is_err());
        assert!(parse_nth_child_selector("1.5").is_err());
    }
    #[test]
    fn parse_nth_child_pattern_malformed_offsets_are_rejected() {
        // Trailing "+" with no offset.
        assert_eq!(
            parse_nth_child_pattern("2n+"),
            Err(CssPseudoSelectorParseError::InvalidNthChildPattern("2n+"))
        );
        assert!(parse_nth_child_pattern("2n+   ").is_err());
        assert!(parse_nth_child_pattern("2n+x").is_err());
        assert!(parse_nth_child_pattern("xn+1").is_err());
        // The `.split('n').next()` / `.split('+').next().unwrap()` pair must never
        // panic, no matter what the input looks like.
        for s in HOSTILE {
            let r = catch(|| parse_nth_child_pattern(s));
            assert!(r.is_ok(), "parse_nth_child_pattern({s:?}) panicked: {}", r.unwrap_err());
        }
    }
    #[test]
    fn parse_nth_child_selector_unicode_never_panics() {
        for v in ["\u{1F600}", "\u{FF12}", "2\u{301}", "e\u{301}ven", "\u{4E2D}n+\u{6587}"] {
            let r = catch(|| parse_nth_child_selector(v));
            assert!(r.is_ok(), "parse_nth_child_selector({v:?}) panicked: {}", r.unwrap_err());
            assert!(
                parse_nth_child_selector(v).is_err(),
                "{v:?} is not a valid nth-child value"
            );
        }
    }
    // --- parse_css_path ---------------------------------------------------
    #[test]
    fn parse_css_path_valid_minimal() {
        // Positive control, mirrors the doc example on `parse_css_path`.
        assert_eq!(
            parse_css_path("* div #my_id > .class:nth-child(2)"),
            Ok(CssPath {
                selectors: vec![
                    CssPathSelector::Global,
                    CssPathSelector::Type(NodeTypeTag::from_str("div").unwrap()),
                    CssPathSelector::Children,
                    CssPathSelector::Id("my_id".to_string().into()),
                    CssPathSelector::DirectChildren,
                    CssPathSelector::Class("class".to_string().into()),
                    CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
                        CssNthChildSelector::Number(2)
                    )),
                ]
                .into()
            })
        );
        assert_eq!(
            parse_css_path("div"),
            Ok(CssPath {
                selectors: vec![CssPathSelector::Type(NodeTypeTag::from_str("div").unwrap())]
                    .into()
            })
        );
    }
    #[test]
    fn parse_css_path_empty_and_whitespace_are_empty_path_errors() {
        assert_eq!(parse_css_path(""), Err(CssPathParseError::EmptyPath));
        assert_eq!(parse_css_path("   "), Err(CssPathParseError::EmptyPath));
        assert_eq!(parse_css_path("\t\r\n "), Err(CssPathParseError::EmptyPath));
        // An unknown type tag is now a hard error (Selectors L4: an invalid simple
        // selector invalidates the selector) rather than being silently dropped into an
        // empty path. See parse_css_path_unknown_type_tag_is_not_silently_dropped.
        assert!(matches!(
            parse_css_path("definitelynotatag"),
            Err(CssPathParseError::NodeTypeTag(_))
        ));
    }
    #[test]
    fn parse_css_path_garbage_and_unicode_never_panic() {
        let long = "div ".repeat(50_000);
        let brackets = "[".repeat(10_000);
        let braces = "{".repeat(10_000);
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long);
        inputs.push(&brackets);
        inputs.push(&braces);
        inputs.push("div;garbage");
        inputs.push("div }");
        inputs.push(":::::");
        inputs.push("\u{1F600} > \u{4E2D}\u{6587}");
        for input in inputs {
            let r = catch(|| parse_css_path(input));
            assert!(r.is_ok(), "parse_css_path({:.40?}) panicked: {}", input, r.unwrap_err());
        }
    }
    #[test]
    fn parse_css_path_rejects_block_tokens() {
        // `{` / `}` are not path tokens; they must not silently produce a path.
        for input in ["div { }", "div {", "}"] {
            assert!(
                parse_css_path(input).is_err(),
                "{input:?} contains block tokens and must not parse as a path"
            );
        }
    }
    #[test]
    fn parse_css_path_unknown_pseudo_selector_is_an_error() {
        assert!(parse_css_path("div:definitelynotapseudo").is_err());
        assert!(parse_css_path(".x:nth-child(notanumber)").is_err());
    }
    /// BUG (red): `parse_css_path` swallows an unknown type selector
    /// (`if let Ok(nt) = NodeTypeTag::from_str(..)` with no `else`), so
    /// `"div definitelynotatag"` parses as `[Type(Div), Children]` -- a path that
    /// ends in a dangling descendant combinator and therefore matches *every*
    /// descendant of `div`, silently widening the selector. It should either be
    /// rejected (like `new_from_str_inner`, which emits a `SkippedRule` warning)
    /// or not leave a trailing combinator behind.
    #[test]
    fn parse_css_path_unknown_type_tag_is_not_silently_dropped() {
        let parsed = parse_css_path("div definitelynotatag");
        if let Ok(path) = &parsed {
            let selectors = path.selectors.as_slice();
            assert!(
                !matches!(
                    selectors.last(),
                    Some(
                        CssPathSelector::Children
                            | CssPathSelector::DirectChildren
                            | CssPathSelector::AdjacentSibling
                            | CssPathSelector::GeneralSibling
                    )
                ),
                "BUG: the unknown type tag was dropped, leaving a dangling combinator; \
                 `div definitelynotatag` now matches every descendant of div. \
                 selectors = {selectors:?}"
            );
        }
    }
    // --- parse_media_conditions / parse_media_feature ---------------------
    #[test]
    fn parse_media_conditions_valid_minimal() {
        assert_eq!(
            parse_media_conditions("screen"),
            vec![DynamicSelector::Media(MediaType::Screen)]
        );
        assert_eq!(
            parse_media_conditions("PRINT"),
            vec![DynamicSelector::Media(MediaType::Print)]
        );
        assert_eq!(
            parse_media_conditions("all"),
            vec![DynamicSelector::Media(MediaType::All)]
        );
    }
    #[test]
    fn parse_media_conditions_parenthesised_and_compound() {
        let conds = parse_media_conditions("(min-width: 800px)");
        assert_eq!(conds.len(), 1);
        match &conds[0] {
            DynamicSelector::ViewportWidth(r) => {
                assert_eq!(r.min(), Some(800.0));
                assert_eq!(r.max(), None);
            }
            other => panic!("expected ViewportWidth, got {other:?}"),
        }
        let conds = parse_media_conditions("screen and (max-width: 600px)");
        assert_eq!(conds.len(), 2, "compound query should yield both conditions");
        assert_eq!(conds[0], DynamicSelector::Media(MediaType::Screen));
        match &conds[1] {
            DynamicSelector::ViewportWidth(r) => {
                assert_eq!(r.min(), None);
                assert_eq!(r.max(), Some(600.0));
            }
            other => panic!("expected ViewportWidth, got {other:?}"),
        }
    }
    #[test]
    fn parse_media_conditions_empty_and_garbage_yield_no_conditions() {
        for input in ["", "   ", "((((", "))))", "\u{1F600}", "and", "(", ")", "()"] {
            let r = catch(|| parse_media_conditions(input));
            match r {
                Ok(conds) => assert!(
                    conds.is_empty(),
                    "{input:?} should not produce media conditions, got {conds:?}"
                ),
                Err(msg) => panic!("parse_media_conditions({input:?}) panicked: {msg}"),
            }
        }
    }
    #[test]
    fn parse_media_conditions_extremely_long_and_deeply_nested_terminate() {
        let nested = format!("({})", "(".repeat(10_000));
        let r = catch(|| parse_media_conditions(&nested));
        assert!(r.is_ok(), "deeply nested media query panicked: {}", r.unwrap_err());
        let long = "screen and ".repeat(20_000);
        let r = catch(|| parse_media_conditions(&long));
        assert!(r.is_ok(), "very long media query panicked: {}", r.unwrap_err());
    }
    #[test]
    fn parse_media_feature_known_features() {
        assert_eq!(
            parse_media_feature("orientation: portrait"),
            Some(DynamicSelector::Orientation(OrientationType::Portrait))
        );
        assert_eq!(
            parse_media_feature("orientation: LANDSCAPE"),
            Some(DynamicSelector::Orientation(OrientationType::Landscape))
        );
        assert_eq!(
            parse_media_feature("prefers-color-scheme: dark"),
            Some(DynamicSelector::Theme(ThemeCondition::Dark))
        );
        assert_eq!(
            parse_media_feature("prefers-reduced-motion: reduce"),
            Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True))
        );
        assert_eq!(
            parse_media_feature("prefers-contrast: more"),
            Some(DynamicSelector::PrefersHighContrast(BoolCondition::True))
        );
        // Keys are matched case-insensitively.
        assert!(parse_media_feature("MIN-WIDTH: 800px").is_some());
    }
    #[test]
    fn parse_media_feature_malformed_returns_none() {
        for input in [
            "",
            "   ",
            "nocolon",
            "min-width:",
            "min-width: ",
            "min-width: abc",
            ": 800px",
            "unknown-feature: 800px",
            "orientation: sideways",
            "\u{1F600}: \u{1F600}",
        ] {
            let r = catch(|| parse_media_feature(input));
            match r {
                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
                Err(msg) => panic!("parse_media_feature({input:?}) panicked: {msg}"),
            }
        }
    }
    /// BUG (red): `MinMaxRange` uses `f32::NAN` as its "no bound" sentinel, and
    /// `parse_px_value` happily parses `"NaN"` (Rust's `f32::from_str` accepts it).
    /// So `@media (min-width: NaN)` produces a `ViewportWidth` whose `min()` is
    /// `None` -- a viewport constraint that constrains nothing and therefore
    /// matches *every* viewport, instead of the media query being rejected.
    #[test]
    fn parse_media_feature_nan_width_does_not_erase_the_constraint() {
        for feature in ["min-width: NaN", "min-width: NaNpx", "max-width: nan"] {
            match parse_media_feature(feature) {
                None => {} // acceptable: the feature was rejected outright
                Some(DynamicSelector::ViewportWidth(r)) => {
                    assert!(
                        r.min().is_some() || r.max().is_some(),
                        "BUG: {feature:?} parsed into a ViewportWidth with no bounds at all \
                         (the NaN collided with MinMaxRange's `absent` sentinel), so the \
                         media query silently matches every viewport"
                    );
                }
                Some(other) => panic!("unexpected selector for {feature:?}: {other:?}"),
            }
        }
    }
    // --- parse_px_value ---------------------------------------------------
    #[test]
    fn parse_px_value_valid_minimal() {
        assert_eq!(parse_px_value("800px"), Some(800.0));
        assert_eq!(parse_px_value("800"), Some(800.0));
        assert_eq!(parse_px_value("  800px  "), Some(800.0));
        assert_eq!(parse_px_value("0"), Some(0.0));
        assert_eq!(parse_px_value("1.5px"), Some(1.5));
        assert_eq!(parse_px_value("-10px"), Some(-10.0));
    }
    #[test]
    fn parse_px_value_malformed_returns_none() {
        for input in ["", "   ", "px", "abc", "8 0 0", "800pxx", "\u{1F600}", "800%", "--"] {
            let r = catch(|| parse_px_value(input));
            match r {
                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
                Err(msg) => panic!("parse_px_value({input:?}) panicked: {msg}"),
            }
        }
    }
    /// f32 range boundaries: overflow saturates to +/-inf and underflow to zero
    /// (that is `f32::from_str`'s documented behaviour) -- neither may panic.
    #[test]
    fn parse_px_value_overflow_and_underflow_saturate_without_panicking() {
        assert_eq!(parse_px_value("-0"), Some(-0.0));
        assert_eq!(parse_px_value("1e-50"), Some(0.0));
        assert_eq!(parse_px_value("3.4e38px"), Some(3.4e38));
        // FIXED: parse_px_value now rejects non-finite results (see
        // parse_px_value_rejects_non_finite_values). "1e39" is valid CSS number
        // *syntax* but overflows f32 to infinity, and an infinite length is exactly
        // the non-finite value that would collide with MinMaxRange's NaN "no bound"
        // sentinel — so it is rejected rather than saturated. (Was: Some(inf).)
        assert_eq!(parse_px_value("1e39px"), None);
        let huge_digits = "9".repeat(100_000);
        let r = catch(|| parse_px_value(&huge_digits));
        assert!(r.is_ok(), "a 100k-digit number panicked: {}", r.unwrap_err());
    }
    /// BUG (red): `f32::from_str` accepts `"NaN"`, `"inf"` and `"infinity"`, none of
    /// which are valid CSS `<length>` values. Because `MinMaxRange` encodes "no
    /// bound" as `NaN`, letting a NaN through silently turns a constraint into a
    /// wildcard (see `parse_media_feature_nan_width_does_not_erase_the_constraint`).
    /// `parse_px_value` should reject non-finite values at the source.
    #[test]
    fn parse_px_value_rejects_non_finite_values() {
        for input in ["NaN", "nan", "inf", "-inf", "infinity", "NaNpx", "infpx", "-infpx"] {
            if let Some(px) = parse_px_value(input) {
                assert!(
                    px.is_finite(),
                    "BUG: parse_px_value({input:?}) returned the non-finite value {px}; \
                     a non-finite length is not valid CSS and collides with MinMaxRange's \
                     NaN `absent` sentinel"
                );
            }
        }
    }
    // --- parse_ratio_value ------------------------------------------------
    #[test]
    fn parse_ratio_value_valid_minimal() {
        let r = parse_ratio_value("16/9").expect("16/9 should parse");
        assert!((r - (16.0 / 9.0)).abs() < 1e-6, "16/9 parsed as {r}");
        let r = parse_ratio_value("1.777").expect("bare float should parse");
        assert!((r - 1.777).abs() < 1e-6);
        let r = parse_ratio_value("  16 / 9  ").expect("whitespace should be trimmed");
        assert!((r - (16.0 / 9.0)).abs() < 1e-6);
    }
    #[test]
    fn parse_ratio_value_division_by_zero_is_rejected() {
        // Both +0.0 and -0.0 denominators must be caught by the `den == 0.0` guard.
        assert_eq!(parse_ratio_value("1/0"), None);
        assert_eq!(parse_ratio_value("1/-0"), None);
        assert_eq!(parse_ratio_value("0/0"), None);
        assert_eq!(parse_ratio_value("16/0.0"), None);
    }
    #[test]
    fn parse_ratio_value_malformed_returns_none() {
        for input in ["", "   ", "/", "16/", "/9", "a/b", "1/2/3", "\u{1F600}", "16:9"] {
            let r = catch(|| parse_ratio_value(input));
            match r {
                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
                Err(msg) => panic!("parse_ratio_value({input:?}) panicked: {msg}"),
            }
        }
    }
    /// BUG (red): the `den == 0.0` guard catches division by zero but not the
    /// non-finite operands that produce NaN anyway -- `inf/inf` and `1/NaN` both
    /// yield NaN, which then becomes MinMaxRange's "no bound" sentinel and turns
    /// an `aspect-ratio` query into a wildcard.
    #[test]
    fn parse_ratio_value_never_returns_nan() {
        for input in ["NaN", "inf/inf", "NaN/1", "1/NaN", "-inf/inf", "inf/-inf"] {
            if let Some(r) = parse_ratio_value(input) {
                assert!(
                    !r.is_nan(),
                    "BUG: parse_ratio_value({input:?}) returned NaN, which MinMaxRange \
                     reads back as `no bound` -- the aspect-ratio query silently matches \
                     everything"
                );
            }
        }
    }
    #[test]
    fn parse_ratio_value_extremely_long_input_terminates() {
        let huge = format!("{}/{}", "9".repeat(50_000), "9".repeat(50_000));
        let r = catch(|| parse_ratio_value(&huge));
        assert!(r.is_ok(), "huge ratio panicked: {}", r.unwrap_err());
    }
    // --- parse_container_conditions / parse_container_feature -------------
    #[test]
    fn parse_container_conditions_valid_minimal() {
        // Bare name.
        assert_eq!(
            parse_container_conditions("sidebar"),
            vec![DynamicSelector::ContainerName(AzString::from(
                "sidebar".to_string()
            ))]
        );
        // Anonymous query.
        let conds = parse_container_conditions("(min-width: 400px)");
        assert_eq!(conds.len(), 1);
        match &conds[0] {
            DynamicSelector::ContainerWidth(r) => {
                assert_eq!(r.min(), Some(400.0));
                assert_eq!(r.max(), None);
            }
            other => panic!("expected ContainerWidth, got {other:?}"),
        }
        // Named query -> name + condition.
        let conds = parse_container_conditions("sidebar (min-width: 400px)");
        assert_eq!(conds.len(), 2);
        assert_eq!(
            conds[0],
            DynamicSelector::ContainerName(AzString::from("sidebar".to_string()))
        );
        assert!(matches!(conds[1], DynamicSelector::ContainerWidth(_)));
    }
    #[test]
    fn parse_container_conditions_empty_and_garbage_never_panic() {
        assert!(parse_container_conditions("").is_empty());
        assert!(parse_container_conditions("   ").is_empty());
        let nested = "(".repeat(10_000);
        let long = "a".repeat(200_000);
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&nested);
        inputs.push(&long);
        for input in inputs {
            let r = catch(|| parse_container_conditions(input));
            assert!(
                r.is_ok(),
                "parse_container_conditions({:.40?}) panicked: {}",
                input,
                r.unwrap_err()
            );
        }
    }
    #[test]
    fn parse_container_feature_malformed_returns_none() {
        for input in ["", "   ", "nocolon", "min-width:", "min-width: abc", "unknown: 1px"] {
            let r = catch(|| parse_container_feature(input));
            match r {
                Ok(v) => assert!(v.is_none(), "{input:?} should be rejected, got {v:?}"),
                Err(msg) => panic!("parse_container_feature({input:?}) panicked: {msg}"),
            }
        }
        assert!(parse_container_feature("min-height: 400px").is_some());
        assert!(parse_container_feature("MAX-WIDTH: 400px").is_some());
    }
    // --- parse_theme_condition / parse_lang_condition ---------------------
    #[test]
    fn parse_theme_condition_valid_minimal() {
        for input in ["dark", "(dark)", "DARK", "\"dark\"", "'dark'", "(\"dark\")", "  dark  "] {
            assert_eq!(
                parse_theme_condition(input),
                Some(DynamicSelector::Theme(ThemeCondition::Dark)),
                "theme {input:?} should resolve to Dark"
            );
        }
        assert_eq!(
            parse_theme_condition("light"),
            Some(DynamicSelector::Theme(ThemeCondition::Light))
        );
    }
    #[test]
    fn parse_theme_condition_garbage_returns_none() {
        for input in ["", "   ", "(", ")", "()", "sepia", "\u{1F600}", "dark light", "\"dark"] {
            let r = catch(|| parse_theme_condition(input));
            match r {
                Ok(v) => assert!(v.is_none(), "theme {input:?} should be rejected, got {v:?}"),
                Err(msg) => panic!("parse_theme_condition({input:?}) panicked: {msg}"),
            }
        }
    }
    #[test]
    fn parse_lang_condition_valid_minimal() {
        for input in ["de-DE", "(de-DE)", "(\"de-DE\")", "('de-DE')", "  de-DE  "] {
            assert_eq!(
                parse_lang_condition(input),
                Some(DynamicSelector::Language(LanguageCondition::Prefix(
                    AzString::from("de-DE".to_string())
                ))),
                "lang {input:?} should resolve to Prefix(de-DE)"
            );
        }
    }
    #[test]
    fn parse_lang_condition_empty_returns_none() {
        for input in ["", "   ", "()", "(  )", "( )"] {
            assert_eq!(
                parse_lang_condition(input),
                None,
                "empty lang {input:?} must not produce a condition"
            );
        }
    }
    #[test]
    fn parse_lang_condition_unicode_and_long_input_never_panic() {
        let long = "a".repeat(200_000);
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long);
        for input in inputs {
            let r = catch(|| parse_lang_condition(input));
            assert!(
                r.is_ok(),
                "parse_lang_condition({:.40?}) panicked: {}",
                input,
                r.unwrap_err()
            );
        }
    }
    // --- css variables ----------------------------------------------------
    #[test]
    fn parse_css_variable_brace_contents_valid_minimal() {
        assert_eq!(
            parse_css_variable_brace_contents("--main-bg-col"),
            Some(("main-bg-col", None))
        );
        let (name, default) = parse_css_variable_brace_contents("--main-bg-col, blue")
            .expect("var with default should parse");
        assert_eq!(name, "main-bg-col");
        // NOTE: the default is returned *untrimmed* (" blue"); `parse_css_property`
        // trims it later, so assert on the trimmed form to stay fix-stable.
        assert_eq!(default.map(str::trim), Some("blue"));
    }
    #[test]
    fn parse_css_variable_brace_contents_rejects_non_variables() {
        for input in ["", "   ", "main-bg-col", "-main-bg-col", "blue", "\u{1F600}", ","] {
            assert_eq!(
                parse_css_variable_brace_contents(input),
                None,
                "{input:?} is not a `--` prefixed CSS variable"
            );
        }
    }
    /// The function slices `&var_name[2..]` after a `starts_with("--")` check.
    /// `--` is ASCII, so byte 2 is always a char boundary even when the variable
    /// name itself is multi-byte.
    #[test]
    fn parse_css_variable_brace_contents_multibyte_name_does_not_split_a_char() {
        assert_eq!(
            parse_css_variable_brace_contents("--\u{1F600}"),
            Some(("\u{1F600}", None))
        );
        // An empty name after `--` is currently accepted; assert only that it is safe.
        let r = catch(|| parse_css_variable_brace_contents("--"));
        assert!(r.is_ok(), "`--` panicked: {}", r.unwrap_err());
    }
    #[test]
    fn check_if_value_is_css_var_recognises_var_syntax() {
        // Not a var() at all.
        assert!(check_if_value_is_css_var("100px").is_none());
        assert!(check_if_value_is_css_var("").is_none());
        assert!(check_if_value_is_css_var("calc(1px + 2px)").is_none());
        // A var() without a default falls back to "none".
        match check_if_value_is_css_var("var(--main-bg-color)") {
            Some(Ok((id, default))) => {
                assert_eq!(id, "main-bg-color");
                assert_eq!(default, "none");
            }
            other => panic!("expected Some(Ok(..)), got {other:?}"),
        }
        // A var() with a default returns it.
        match check_if_value_is_css_var("var(--w, 100px)") {
            Some(Ok((id, default))) => {
                assert_eq!(id, "w");
                assert_eq!(default.trim(), "100px");
            }
            other => panic!("expected Some(Ok(..)), got {other:?}"),
        }
        // Malformed brace contents surface as an error, not a panic and not a None.
        assert!(matches!(
            check_if_value_is_css_var("var(nonsense)"),
            Some(Err(CssParseErrorInner::DynamicCssParseError(
                DynamicCssParseError::InvalidBraceContents(_)
            )))
        ));
        assert!(matches!(
            check_if_value_is_css_var("var()"),
            Some(Err(CssParseErrorInner::DynamicCssParseError(
                DynamicCssParseError::InvalidBraceContents(_)
            )))
        ));
    }
    #[test]
    fn check_if_value_is_css_var_hostile_input_never_panics() {
        let long = format!("var(--{})", "x".repeat(100_000));
        let nested = format!("var({})", "(".repeat(10_000));
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long);
        inputs.push(&nested);
        inputs.push("var(");
        inputs.push("var)");
        inputs.push("var((--x))");
        for input in inputs {
            let r = catch(|| check_if_value_is_css_var(input).is_some());
            assert!(
                r.is_ok(),
                "check_if_value_is_css_var({:.40?}) panicked: {}",
                input,
                r.unwrap_err()
            );
        }
    }
    // --- parse_declaration_resilient / parse_css_declaration --------------
    #[test]
    fn parse_css_declaration_valid_minimal() {
        let km = key_map();
        let mut warnings = Vec::new();
        let mut declarations = Vec::new();
        let r = parse_css_declaration(
            "width",
            "100px",
            loc(0, 0),
            &km,
            &mut warnings,
            &mut declarations,
        );
        assert_eq!(r, Ok(()));
        assert_eq!(declarations.len(), 1);
        assert!(matches!(declarations[0], CssDeclaration::Static(_)));
        assert!(warnings.is_empty());
    }
    #[test]
    fn parse_css_declaration_unknown_key_is_downgraded_to_a_warning() {
        let km = key_map();
        let mut warnings = Vec::new();
        let mut declarations = Vec::new();
        // Documented contract: an unknown key is a warning, not a hard error, so the
        // caller can keep processing the rest of the block.
        let r = parse_css_declaration(
            "definitely-not-a-property",
            "1",
            loc(0, 0),
            &km,
            &mut warnings,
            &mut declarations,
        );
        assert_eq!(r, Ok(()));
        assert!(declarations.is_empty());
        assert_eq!(warnings.len(), 1);
        assert!(matches!(
            warnings[0].warning,
            CssParseWarnMsgInner::UnsupportedKeyValuePair { .. }
        ));
    }
    #[test]
    fn parse_css_declaration_bad_value_is_a_hard_error() {
        let km = key_map();
        let mut warnings = Vec::new();
        let mut declarations = Vec::new();
        let r = parse_css_declaration(
            "width",
            "definitely-not-a-length",
            loc(0, 0),
            &km,
            &mut warnings,
            &mut declarations,
        );
        assert!(r.is_err(), "a known key with an unparseable value must error");
        assert!(declarations.is_empty());
    }
    #[test]
    fn parse_declaration_resilient_var_on_shorthand_is_rejected() {
        let km = key_map();
        // `margin` is a shorthand; `var()` on it is ambiguous and must be refused.
        let r = parse_declaration_resilient("margin", "var(--m)", loc(0, 0), &km);
        assert!(
            matches!(r, Err(CssParseErrorInner::VarOnShorthandProperty { .. })),
            "expected VarOnShorthandProperty, got {r:?}"
        );
    }
    #[test]
    fn parse_declaration_resilient_var_on_normal_property_becomes_dynamic() {
        let km = key_map();
        let decls = parse_declaration_resilient("width", "var(--w, 100px)", loc(0, 0), &km)
            .expect("var() on a non-shorthand property should parse");
        assert_eq!(decls.len(), 1);
        match &decls[0] {
            CssDeclaration::Dynamic(DynamicCssProperty { dynamic_id, .. }) => {
                assert_eq!(dynamic_id.as_str(), "w");
            }
            other => panic!("expected a Dynamic declaration, got {other:?}"),
        }
    }
    #[test]
    fn parse_declaration_resilient_hostile_key_value_pairs_never_panic() {
        let km = key_map();
        let long = "x".repeat(100_000);
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long);
        for key in &inputs {
            for value in &inputs {
                let r = catch(|| parse_declaration_resilient(key, value, loc(0, 0), &km).is_ok());
                assert!(
                    r.is_ok(),
                    "parse_declaration_resilient({:.30?}, {:.30?}) panicked: {}",
                    key,
                    value,
                    r.unwrap_err()
                );
            }
        }
    }
    #[test]
    fn parse_declaration_resilient_empty_key_is_an_unknown_property() {
        let km = key_map();
        assert!(matches!(
            parse_declaration_resilient("", "", loc(0, 0), &km),
            Err(CssParseErrorInner::UnknownPropertyKey("", ""))
        ));
    }
    // =====================================================================
    // numeric -> overflow / NaN / saturation / limits
    // =====================================================================
    #[test]
    fn get_line_column_from_error_representative_values() {
        let css = "div {\n    width: 100px;\n}";
        // Position 0 and 1 both clamp to offset 0 via `saturating_sub(1)`.
        assert_eq!(
            ErrorLocation { original_pos: 0 }.get_line_column_from_error(css),
            (0, 0)
        );
        let (line, _col) = ErrorLocation { original_pos: 12 }.get_line_column_from_error(css);
        assert_eq!(line, 2, "byte 11 is on the second line");
    }
    #[test]
    fn get_line_column_from_error_empty_css_does_not_panic() {
        let r = catch(|| ErrorLocation { original_pos: 0 }.get_line_column_from_error(""));
        assert_eq!(r, Ok((0, 0)));
    }
    /// The column arithmetic (`error_location - total_characters.saturating_sub(2)`)
    /// is an unchecked subtraction; newline-heavy inputs are the worst case for it.
    #[test]
    fn get_line_column_from_error_newline_heavy_input_does_not_underflow() {
        let newlines = "\n".repeat(1_000);
        let crlf = "\r\n".repeat(1_000);
        for css in [newlines.as_str(), crlf.as_str()] {
            for pos in [1_usize, 2, 3, 500, css.len()] {
                let r = catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css));
                assert!(
                    r.is_ok(),
                    "get_line_column_from_error(pos={pos}) underflowed/panicked: {}",
                    r.unwrap_err()
                );
            }
        }
    }
    /// BUG (red): `css_string[0..error_location]` is an unchecked slice. An
    /// `original_pos` past the end of the string -- trivially reachable, since
    /// `ErrorLocation` is a `pub` struct with a `pub` field and the method takes an
    /// arbitrary `&str` -- panics with "byte index out of bounds" instead of
    /// clamping.
    #[test]
    fn get_line_column_from_error_out_of_range_pos_does_not_panic() {
        let css = "div {}";
        for pos in [css.len() + 2, 999, usize::MAX] {
            if let Err(msg) =
                catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css))
            {
                panic!(
                    "BUG: get_line_column_from_error panicked for original_pos={pos} on a \
                     {}-byte string (unchecked `css_string[0..error_location]` slice); it \
                     should clamp instead: {msg}",
                    css.len()
                );
            }
        }
    }
    /// BUG (red): the same unchecked slice also ignores UTF-8 char boundaries.
    /// `original_pos = css.len()` is exactly what `get_error_location` records at
    /// `Token::EndOfStream`, so a stylesheet whose last character is multi-byte
    /// makes `original_pos - 1` land *inside* that character and the slice panics
    /// with "byte index is not a char boundary".
    #[test]
    fn get_line_column_from_error_at_end_of_unicode_css_does_not_panic() {
        // "a\u{1F600}" is 5 bytes; the only char boundaries are 0, 1 and 5.
        let css = "a\u{1F600}";
        assert_eq!(css.len(), 5);
        let pos = css.len(); // -> error_location == 4, which is mid-emoji
        if let Err(msg) =
            catch(|| ErrorLocation { original_pos: pos }.get_line_column_from_error(css))
        {
            panic!(
                "BUG: get_line_column_from_error panicked at end-of-stream (original_pos={pos}) \
                 because the CSS ends with a multi-byte char and `original_pos - 1` splits it: \
                 {msg}"
            );
        }
    }
    // =====================================================================
    // getters / predicates -> invariants
    // =====================================================================
    #[test]
    fn get_error_string_returns_the_trimmed_slice_between_start_and_end() {
        let css = "div { width: 100px; }";
        let err = CssParseError {
            css_string: css,
            error: CssParseErrorInner::MalformedCss,
            location: loc(6, 18),
        };
        assert_eq!(err.get_error_string(), "width: 100px");
        // An empty range yields an empty string rather than panicking.
        let err = CssParseError {
            css_string: css,
            error: CssParseErrorInner::MalformedCss,
            location: loc(0, 0),
        };
        assert_eq!(err.get_error_string(), "");
    }
    /// BUG (red): `get_error_string` slices `&self.css_string[start..end]` with no
    /// validation. A location that is out of range, reversed, or lands inside a
    /// multi-byte char panics. `CssParseError` is `pub` with `pub` fields (and is
    /// rebuilt from an owned value by `CssParseErrorOwned::to_shared`, where nothing
    /// re-checks the location against the string), so this is reachable.
    #[test]
    fn get_error_string_invalid_location_does_not_panic() {
        let cases: [(&str, ErrorLocationRange, &str); 3] = [
            ("div", loc(0, 99), "end past the end of the string"),
            ("div", loc(2, 1), "reversed range (start > end)"),
            ("a\u{1F600}", loc(0, 4), "end inside a multi-byte char"),
        ];
        for (css, location, why) in cases {
            let err = CssParseError {
                css_string: css,
                error: CssParseErrorInner::MalformedCss,
                location,
            };
            if let Err(msg) = catch(|| err.get_error_string().to_string()) {
                panic!(
                    "BUG: get_error_string panicked on an invalid location ({why}) instead of \
                     returning an empty/clamped slice: {msg}"
                );
            }
        }
    }
    // --- serializer: Display for CssParseError ----------------------------
    #[test]
    fn display_of_css_parse_error_is_non_empty_and_well_formed() {
        let css = "div { width: 100px; }";
        let err = CssParseError {
            css_string: css,
            error: CssParseErrorInner::MalformedCss,
            location: loc(6, 18),
        };
        let s = format!("{err}");
        assert!(!s.is_empty());
        assert!(s.contains("start: line"), "missing start location: {s}");
        assert!(s.contains("end: line"), "missing end location: {s}");
        assert!(s.contains("width: 100px"), "missing offending text: {s}");
        assert!(s.contains("Malformed Css"), "missing reason: {s}");
    }
    #[test]
    fn display_of_css_parse_error_on_zero_value_does_not_panic() {
        let err = CssParseError {
            css_string: "",
            error: CssParseErrorInner::UnclosedBlock,
            location: ErrorLocationRange::default(),
        };
        let r = catch(|| format!("{err}"));
        match r {
            Ok(s) => assert!(!s.is_empty(), "Display produced an empty string"),
            Err(msg) => panic!("Display panicked on a zero-valued CssParseError: {msg}"),
        }
    }
    /// BUG (red): `Display for CssParseError` calls both `get_line_column_from_error`
    /// and `get_error_string`, so it inherits their unchecked slicing. Formatting the
    /// error for a stylesheet that ends in a multi-byte character panics -- i.e. the
    /// *error reporting path* itself crashes on non-ASCII CSS.
    #[test]
    fn display_of_css_parse_error_with_unicode_css_does_not_panic() {
        let css = "p{}\u{1F600}"; // 7 bytes; boundaries at 0..=3 and 7
        let err = CssParseError {
            css_string: css,
            error: CssParseErrorInner::MalformedCss,
            location: loc(0, css.len()),
        };
        if let Err(msg) = catch(|| format!("{err}")) {
            panic!(
                "BUG: Display for CssParseError panicked while formatting an error whose CSS \
                 ends in a multi-byte char (unchecked slicing in get_line_column_from_error / \
                 get_error_string): {msg}"
            );
        }
    }
    #[test]
    fn display_of_error_and_warning_inners_is_never_empty() {
        let km = key_map();
        let margin = CombinedCssPropertyType::from_str("margin", &km).expect("margin is a shorthand");
        let inners: Vec<CssParseErrorInner<'_>> = vec![
            CssParseErrorInner::ParseError(CssSyntaxError::UnknownToken(CssSyntaxErrorPos {
                row: usize::MAX,
                col: usize::MAX,
            })),
            CssParseErrorInner::UnclosedBlock,
            CssParseErrorInner::MalformedCss,
            CssParseErrorInner::DynamicCssParseError(DynamicCssParseError::InvalidBraceContents(
                "",
            )),
            CssParseErrorInner::PseudoSelectorParseError(
                CssPseudoSelectorParseError::EmptyNthChild,
            ),
            CssParseErrorInner::NodeTypeTag(NodeTypeTagParseError::Invalid("")),
            CssParseErrorInner::UnknownPropertyKey("", ""),
            CssParseErrorInner::VarOnShorthandProperty {
                key: margin,
                value: "",
            },
        ];
        for inner in &inners {
            let s = format!("{inner}");
            assert!(!s.is_empty(), "empty Display for {inner:?}");
        }
        let warnings = vec![
            CssParseWarnMsgInner::UnsupportedKeyValuePair { key: "", value: "" },
            CssParseWarnMsgInner::ParseError(CssParseErrorInner::MalformedCss),
            CssParseWarnMsgInner::SkippedRule {
                selector: None,
                error: CssParseErrorInner::UnclosedBlock,
            },
            CssParseWarnMsgInner::SkippedDeclaration {
                key: "",
                value: "",
                error: CssParseErrorInner::MalformedCss,
            },
            CssParseWarnMsgInner::MalformedStructure { message: "" },
        ];
        for w in &warnings {
            assert!(!format!("{w}").is_empty(), "empty Display for {w:?}");
        }
    }
    // =====================================================================
    // round-trip -> to_contained() == to_shared()
    // =====================================================================
    #[test]
    fn css_pseudo_selector_parse_error_round_trips() {
        let cases = vec![
            CssPseudoSelectorParseError::EmptyNthChild,
            CssPseudoSelectorParseError::UnknownSelector("blah", None),
            CssPseudoSelectorParseError::UnknownSelector("blah", Some("3")),
            CssPseudoSelectorParseError::InvalidNthChildPattern("2x+1"),
            CssPseudoSelectorParseError::InvalidNthChild(
                "x".parse::<u32>().unwrap_err(),
            ),
            CssPseudoSelectorParseError::InvalidNthChild(
                "99999999999999999999".parse::<u32>().unwrap_err(),
            ),
            // Empty / extreme payloads.
            CssPseudoSelectorParseError::UnknownSelector("", Some("")),
        ];
        for case in &cases {
            assert_eq!(
                &case.to_contained().to_shared(),
                case,
                "round-trip changed the value"
            );
        }
    }
    #[test]
    fn dynamic_css_parse_error_round_trips() {
        let simple = DynamicCssParseError::InvalidBraceContents("--x, blue");
        assert_eq!(&simple.to_contained().to_shared(), &simple);
        let empty = DynamicCssParseError::InvalidBraceContents("");
        assert_eq!(&empty.to_contained().to_shared(), &empty);
        // A real `CssParsingError` from the property parser. The nested error has its
        // own owned/shared pair, so compare via `Display` (semantics) plus the variant.
        let km = key_map();
        let width = CssPropertyType::from_str("width", &km).expect("width is a property");
        let inner = parse_css_property(width, "definitely-not-a-length")
            .expect_err("an invalid length must fail to parse");
        let wrapped = DynamicCssParseError::UnexpectedValue(inner);
        let round_tripped = wrapped.to_contained();
        let back = round_tripped.to_shared();
        assert!(matches!(back, DynamicCssParseError::UnexpectedValue(_)));
        assert_eq!(
            format!("{back}"),
            format!("{wrapped}"),
            "round-trip lost information from the nested CssParsingError"
        );
    }
    #[test]
    fn css_parse_error_inner_round_trips_for_every_variant() {
        let km = key_map();
        let margin =
            CombinedCssPropertyType::from_str("margin", &km).expect("margin is a shorthand");
        let cases = vec![
            CssParseErrorInner::ParseError(CssSyntaxError::UnexpectedEndOfStream(
                CssSyntaxErrorPos { row: 0, col: 0 },
            )),
            // Extreme numeric payloads must survive the FFI hop unchanged.
            CssParseErrorInner::ParseError(CssSyntaxError::InvalidAdvance(
                CssSyntaxInvalidAdvance {
                    expected: isize::MIN,
                    total: usize::MAX,
                    pos: CssSyntaxErrorPos {
                        row: usize::MAX,
                        col: usize::MAX,
                    },
                },
            )),
            CssParseErrorInner::ParseError(CssSyntaxError::UnsupportedToken(CssSyntaxErrorPos {
                row: 3,
                col: 7,
            })),
            CssParseErrorInner::UnclosedBlock,
            CssParseErrorInner::MalformedCss,
            CssParseErrorInner::DynamicCssParseError(DynamicCssParseError::InvalidBraceContents(
                "--x",
            )),
            CssParseErrorInner::PseudoSelectorParseError(
                CssPseudoSelectorParseError::EmptyNthChild,
            ),
            CssParseErrorInner::NodeTypeTag(NodeTypeTagParseError::Invalid("notatag")),
            CssParseErrorInner::UnknownPropertyKey("key", "value"),
            CssParseErrorInner::UnknownPropertyKey("", ""),
            CssParseErrorInner::VarOnShorthandProperty {
                key: margin,
                value: "var(--m)",
            },
        ];
        for case in &cases {
            assert_eq!(
                &case.to_contained().to_shared(),
                case,
                "round-trip changed the value for {case:?}"
            );
        }
    }
    #[test]
    fn css_parse_error_round_trips_including_unicode_payloads() {
        let css = "div { width: \u{1F600}; }";
        let err = CssParseError {
            css_string: css,
            error: CssParseErrorInner::UnknownPropertyKey("k\u{1F600}", "v\u{4E2D}"),
            location: loc(1, 2),
        };
        assert_eq!(err.to_contained().to_shared(), err);
        // Zero value.
        let err = CssParseError {
            css_string: "",
            error: CssParseErrorInner::MalformedCss,
            location: ErrorLocationRange::default(),
        };
        assert_eq!(err.to_contained().to_shared(), err);
    }
    #[test]
    fn css_path_parse_error_round_trips_for_every_variant() {
        let cases = vec![
            CssPathParseError::EmptyPath,
            CssPathParseError::InvalidTokenEncountered("{"),
            CssPathParseError::InvalidTokenEncountered(""),
            CssPathParseError::UnexpectedEndOfStream("div"),
            CssPathParseError::SyntaxError(CssSyntaxError::UnknownToken(CssSyntaxErrorPos {
                row: usize::MAX,
                col: 0,
            })),
            CssPathParseError::NodeTypeTag(NodeTypeTagParseError::Invalid("notatag")),
            CssPathParseError::PseudoSelectorParseError(
                CssPseudoSelectorParseError::InvalidNthChildPattern("2x"),
            ),
        ];
        for case in &cases {
            assert_eq!(
                &case.to_contained().to_shared(),
                case,
                "round-trip changed the value for {case:?}"
            );
        }
    }
    #[test]
    fn css_parse_warn_msg_round_trips_for_every_variant() {
        let inners = vec![
            CssParseWarnMsgInner::UnsupportedKeyValuePair {
                key: "foo",
                value: "bar",
            },
            CssParseWarnMsgInner::UnsupportedKeyValuePair { key: "", value: "" },
            CssParseWarnMsgInner::ParseError(CssParseErrorInner::MalformedCss),
            CssParseWarnMsgInner::SkippedRule {
                selector: None,
                error: CssParseErrorInner::UnclosedBlock,
            },
            CssParseWarnMsgInner::SkippedRule {
                selector: Some("div"),
                error: CssParseErrorInner::MalformedCss,
            },
            CssParseWarnMsgInner::SkippedDeclaration {
                key: "width",
                value: "\u{1F600}",
                error: CssParseErrorInner::MalformedCss,
            },
            CssParseWarnMsgInner::MalformedStructure {
                message: "unclosed",
            },
        ];
        for inner in &inners {
            assert_eq!(
                &inner.to_contained().to_shared(),
                inner,
                "round-trip changed the value for {inner:?}"
            );
            let msg = CssParseWarnMsg {
                warning: inner.clone(),
                location: loc(7, 42),
            };
            let back = msg.to_contained();
            let back = back.to_shared();
            assert_eq!(back, msg, "CssParseWarnMsg round-trip changed the value");
            assert_eq!(back.location, loc(7, 42), "location was not preserved");
        }
    }
    #[test]
    fn unparsed_css_rule_block_round_trips() {
        let mut declarations = BTreeMap::new();
        declarations.insert("width", ("100px", loc(1, 2)));
        declarations.insert("color", ("\u{1F600}", loc(3, 4)));
        let block = UnparsedCssRuleBlock {
            path: CssPath {
                selectors: vec![
                    CssPathSelector::Global,
                    CssPathSelector::Class("btn".to_string().into()),
                ]
                .into(),
            },
            declarations,
            // NB: deliberately no MinMaxRange condition here -- those store `f32::NAN`
            // as the "absent bound" sentinel, so they are not equal to themselves under
            // the derived `PartialEq` (see dynamic_selector.rs).
            conditions: vec![DynamicSelector::Media(MediaType::Screen)],
        };
        assert_eq!(block.to_contained().to_shared(), block);
        // Empty instance.
        let empty = UnparsedCssRuleBlock {
            path: CssPath {
                selectors: Vec::new().into(),
            },
            declarations: BTreeMap::new(),
            conditions: Vec::new(),
        };
        assert_eq!(empty.to_contained().to_shared(), empty);
    }
    // =====================================================================
    // other -> new_from_str / new_from_str_inner / css_blocks_to_stylesheet
    // =====================================================================
    #[test]
    fn new_from_str_valid_minimal() {
        let (css, warnings) = new_from_str("div { width: 100px; }");
        assert_eq!(css.rules.len(), 1);
        assert_eq!(css.rules.as_slice()[0].declarations.len(), 1);
        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
    }
    #[test]
    fn new_from_str_empty_input_yields_an_empty_stylesheet() {
        let (css, warnings) = new_from_str("");
        assert_eq!(css.rules.len(), 0);
        assert!(warnings.is_empty());
        let (css, _warnings) = new_from_str("   \n\t  ");
        assert_eq!(css.rules.len(), 0);
    }
    #[test]
    fn new_from_str_unclosed_block_warns_instead_of_failing() {
        let (css, warnings) = new_from_str("div { width: 100px;");
        assert_eq!(css.rules.len(), 0, "an unclosed block emits no rules");
        assert!(
            warnings.iter().any(|w| matches!(
                w.warning,
                CssParseWarnMsgInner::MalformedStructure { .. }
            )),
            "expected a MalformedStructure warning, got {warnings:?}"
        );
    }
    #[test]
    fn new_from_str_unknown_property_is_a_warning_not_a_dropped_rule() {
        let (css, warnings) = new_from_str("div { definitely-not-a-property: 1; width: 10px; }");
        assert_eq!(css.rules.len(), 1);
        // The unknown key is skipped but the valid declaration survives.
        assert_eq!(css.rules.as_slice()[0].declarations.len(), 1);
        assert!(!warnings.is_empty(), "the unknown key should have warned");
    }
    #[test]
    fn var_reference_resolves_against_a_root_custom_property() {
        let (css, _) = new_from_str(":root{--boxw:150px} .v{width:var(--boxw)}");
        // The `--boxw` DEFINITION emits no declaration; the `var(--boxw)` REFERENCE is
        // resolved to a concrete Static value, so exactly one declaration survives overall.
        let decls: Vec<_> = css
            .rules
            .as_slice()
            .iter()
            .flat_map(|r| r.declarations.as_slice().iter())
            .collect();
        assert_eq!(decls.len(), 1, "custom-prop def emits nothing, var() resolves: {decls:?}");
        // The resolved declaration is identical to a direct `width:150px`.
        let (direct, _) = new_from_str(".v{width:150px}");
        assert_eq!(decls[0], &direct.rules.as_slice()[0].declarations.as_slice()[0]);
    }
    #[test]
    fn undefined_var_reference_falls_back_to_its_default() {
        let (css, _) = new_from_str(".v{width:var(--nope, 42px)}");
        let (direct, _) = new_from_str(".v{width:42px}");
        assert_eq!(
            css.rules.as_slice()[0].declarations.as_slice()[0],
            direct.rules.as_slice()[0].declarations.as_slice()[0],
        );
    }
    /// `new_from_str` documents "Never panics" -- hold it to that.
    #[test]
    fn new_from_str_hostile_input_never_panics() {
        let long_rule = "div { width: 100px; }".repeat(2_000);
        let deep_nesting = format!("{}{}", "div {".repeat(500), "}".repeat(500));
        let unbalanced_open = "{".repeat(5_000);
        let unbalanced_close = "}".repeat(5_000);
        let long_selector = format!("{} {{ width: 1px; }}", "div ".repeat(10_000));
        let mut inputs: Vec<&str> = HOSTILE.to_vec();
        inputs.push(&long_rule);
        inputs.push(&deep_nesting);
        inputs.push(&unbalanced_open);
        inputs.push(&unbalanced_close);
        inputs.push(&long_selector);
        inputs.push("div { width: \u{1F600}; }");
        inputs.push("\u{1F600} { \u{4E2D}: \u{6587}; }");
        inputs.push("div { width: 100px; /* unterminated");
        inputs.push("@media (min-width: 800px) { div { width: 1px; } }");
        inputs.push("@theme(dark) { div { width: 1px; } }");
        inputs.push("@lang(\"de-DE\") { div { width: 1px; } }");
        inputs.push("@container sidebar (min-width: 400px) { div { width: 1px; } }");
        inputs.push("@definitely-not-an-at-rule x { div { width: 1px; } }");
        inputs.push(".a { .b { :hover { width: 1px; } } }");
        inputs.push("div[data-x=\"y\"] { width: 1px; }");
        inputs.push("div[ { width: 1px; }");
        inputs.push("a, b, , c { width: 1px; }");
        inputs.push("div:nth-child(999999999999) { width: 1px; }");
        for input in inputs {
            let r = catch(|| {
                let (css, warnings) = new_from_str(input);
                (css.rules.len(), warnings.len())
            });
            assert!(
                r.is_ok(),
                "new_from_str({:.60?}) panicked despite the `Never panics` contract: {}",
                input,
                r.unwrap_err()
            );
        }
    }
    #[test]
    fn new_from_str_at_rules_attach_conditions_to_nested_rules() {
        let (css, _warnings) = new_from_str("@media screen { div { width: 1px; } }");
        assert_eq!(css.rules.len(), 1);
        let rule = &css.rules.as_slice()[0];
        assert!(
            rule.conditions
                .as_slice()
                .contains(&DynamicSelector::Media(MediaType::Screen)),
            "the @media condition was not attached: {:?}",
            rule.conditions.as_slice()
        );
    }
    #[test]
    fn new_from_str_comma_separated_selectors_emit_one_rule_each() {
        let (css, _warnings) = new_from_str("div, p { width: 1px; }");
        assert_eq!(css.rules.len(), 2, "each selector in the list gets its own rule");
    }
    #[test]
    fn new_from_str_inner_matches_new_from_str() {
        let css_string = "div { width: 100px; }";
        let mut tokenizer = Tokenizer::new(css_string);
        let mut kf = Vec::new();
        let (rules, warnings) = new_from_str_inner(css_string, &mut tokenizer, &mut kf);
        assert_eq!(rules.len(), 1);
        assert!(warnings.is_empty());
    }
    #[test]
    fn get_error_location_tracks_the_tokenizer_position() {
        let css_string = "div { width: 100px; }";
        let mut tokenizer = Tokenizer::new(css_string);
        assert_eq!(get_error_location(&tokenizer).original_pos, 0);
        let _ = tokenizer.parse_next();
        let after = get_error_location(&tokenizer).original_pos;
        assert!(after > 0, "the tokenizer position did not advance");
        assert!(
            after <= css_string.len(),
            "the tokenizer position ran past the end of the input"
        );
        // Position on an empty document is 0 and must not panic.
        let empty = Tokenizer::new("");
        assert_eq!(get_error_location(&empty).original_pos, 0);
    }
    #[test]
    fn css_blocks_to_stylesheet_parses_known_keys_and_warns_on_unknown_ones() {
        let css_string = "div { width: 100px; }";
        let mut declarations = BTreeMap::new();
        declarations.insert("width", ("100px", loc(6, 18)));
        let good = UnparsedCssRuleBlock {
            path: CssPath {
                selectors: vec![CssPathSelector::Global].into(),
            },
            declarations,
            conditions: Vec::new(),
        };
        let mut declarations = BTreeMap::new();
        declarations.insert("definitely-not-a-property", ("1", loc(0, 1)));
        let bad = UnparsedCssRuleBlock {
            path: CssPath {
                selectors: vec![CssPathSelector::Global].into(),
            },
            declarations,
            conditions: Vec::new(),
        };
        let (rules, warnings) = css_blocks_to_stylesheet(vec![good, bad], css_string);
        assert_eq!(rules.len(), 2, "both blocks are emitted");
        assert_eq!(rules[0].declarations.len(), 1);
        assert_eq!(rules[1].declarations.len(), 0, "the unknown key is dropped");
        assert_eq!(warnings.len(), 1, "the unknown key produced exactly one warning");
        assert!(matches!(
            warnings[0].warning,
            CssParseWarnMsgInner::SkippedDeclaration { .. }
        ));
    }
    #[test]
    fn css_blocks_to_stylesheet_empty_input_is_empty_output() {
        let (rules, warnings) = css_blocks_to_stylesheet(Vec::new(), "");
        assert!(rules.is_empty());
        assert!(warnings.is_empty());
    }
}
#[cfg(test)]
mod keyframes_tests {
    use super::*;
    /// `@keyframes` nested inside `@media` PARSES since the native-tokenizer
    /// rework (the textual extractor was top-level-only and left the block to
    /// garble the rule stream). The keyframes join the flat list; rules
    /// before/inside/after the media block stay intact. Enclosing conditions
    /// do not gate keyframes yet (documented).
    #[test]
1
    fn keyframes_inside_media_parse_and_rules_survive() {
1
        let css = r#"
1
            p { color: red; }
1
            @media (min-width: 100px) {
1
                @keyframes nested { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } }
1
                div { color: blue; }
1
            }
1
            span { color: green; }
1
        "#;
1
        let (parsed, _warnings) = new_from_str(css);
1
        let kf: Vec<_> = parsed.keyframes.as_ref().iter().collect();
1
        assert_eq!(kf.len(), 1, "nested @keyframes must parse: {:?}", parsed.keyframes);
1
        assert_eq!(kf[0].name.as_str(), "nested");
1
        let permilles: Vec<u16> = kf[0].stops.iter().map(|s| s.permille).collect();
1
        assert_eq!(permilles, vec![0, 500, 1000]);
        // All three rules survive with their declarations.
1
        let total_rules: usize = parsed.rules.as_ref().len();
1
        assert_eq!(total_rules, 3, "p + div + span: {:#?}", parsed.rules);
1
    }
    /// A commented-out `@keyframes` must NOT register. The old textual
    /// scanner ran `find("@keyframes")` with no comment awareness and
    /// extracted from INSIDE `/* .. */`; the tokenizer skips comments.
    #[test]
1
    fn keyframes_inside_comment_do_not_register() {
1
        let css = r#"
1
            /* @keyframes ghost { from { opacity: 0; } to { opacity: 1; } } */
1
            p { color: red; }
1
        "#;
1
        let (parsed, _warnings) = new_from_str(css);
1
        assert_eq!(
1
            parsed.keyframes.as_ref().len(),
            0,
            "commented-out @keyframes registered: {:?}",
            parsed.keyframes
        );
1
        assert_eq!(parsed.rules.as_ref().len(), 1);
1
    }
    /// Fractional percent stops (`62.5%`) keep parsing through the native
    /// path (tokenized as one TypeSelector since azul-simplecss 0.2.1), and
    /// a comma list shares its declaration set across stops.
    #[test]
1
    fn keyframes_fractional_and_comma_list_stops() {
1
        let css = "@keyframes k { 62.5%, to { opacity: 1; } }";
1
        let (parsed, _warnings) = new_from_str(css);
1
        let kf: Vec<_> = parsed.keyframes.as_ref().iter().collect();
1
        assert_eq!(kf.len(), 1);
1
        let permilles: Vec<u16> = kf[0].stops.iter().map(|s| s.permille).collect();
1
        assert_eq!(permilles, vec![625, 1000]);
1
        assert_eq!(kf[0].stops.as_ref()[0].props.as_ref().len(), 1);
1
        assert_eq!(kf[0].stops.as_ref()[1].props.as_ref().len(), 1);
1
    }
    /// `@keyframes` parse + the rule parser skipping the block: the stops
    /// come out sorted with their properties, and the rules AROUND the block
    /// still parse as if it were not there (same count, same declarations).
    #[test]
1
    fn keyframes_parse_and_do_not_disturb_rules() {
1
        let css = r#"
1
            div { width: 50px; }
1
            @keyframes flyOutRight {
1
                from { transform: translateX(0px); opacity: 1; }
1
                50% { opacity: 0.75; }
1
                to { transform: translateX(200px); width: 0px; }
1
            }
1
            p { height: 10px; }
1
        "#;
1
        let (parsed, warnings) = new_from_str(css);
1
        assert!(
1
            warnings.is_empty(),
            "keyframes must not produce rule-parser warnings: {warnings:#?}"
        );
1
        assert_eq!(parsed.keyframes.as_ref().len(), 1);
1
        let kf = &parsed.keyframes.as_ref()[0];
1
        assert_eq!(kf.name.as_str(), "flyOutRight");
1
        let stops = kf.stops.as_ref();
1
        assert_eq!(stops.len(), 3);
1
        assert_eq!(stops[0].permille, 0);
1
        assert_eq!(stops[1].permille, 500);
1
        assert_eq!(stops[2].permille, 1000);
1
        assert_eq!(stops[0].props.as_ref().len(), 2, "from: transform + opacity");
1
        assert_eq!(stops[2].props.as_ref().len(), 2, "to: transform + width");
        // The surrounding rules are intact — the block was skipped, not
        // half-tokenised into junk selectors.
1
        let (no_kf, _) = new_from_str("div { width: 50px; } p { height: 10px; }");
1
        assert_eq!(parsed.rules.as_ref().len(), no_kf.rules.as_ref().len());
1
    }
    /// The three animation properties parse through the ordinary declaration
    /// path with name/duration/timing, and `-azul-`-prefixed names resolve.
    #[test]
1
    fn animation_properties_parse() {
1
        let css = r#"
1
            #sidebar {
1
                -azul-animation-out: flyOutRight 1s;
1
                -azul-animation-in: flyInLeft 500ms spring;
1
                animation: all 2s ease-out;
1
            }
1
        "#;
1
        let (parsed, warnings) = new_from_str(css);
1
        assert!(warnings.is_empty(), "{warnings:#?}");
1
        let rules = parsed.rules.as_ref();
1
        assert_eq!(rules.len(), 1);
1
        let decls = rules[0].declarations.as_ref();
1
        assert_eq!(decls.len(), 3, "{decls:#?}");
1
        let mut found_out = false;
1
        let mut found_all = false;
4
        for d in decls {
3
            let crate::css::CssDeclaration::Static(prop) = d else {
                continue;
            };
3
            if let crate::props::property::CssProperty::AnimationOut(v) = prop {
1
                let list = v.get_property().cloned().unwrap_or_default();
1
                let a = &list.as_ref()[0];
1
                assert_eq!(a.name.as_str(), "flyOutRight");
1
                assert_eq!(
                    a.duration,
1
                    crate::props::basic::time::CssDuration::from_millis(1000)
                );
1
                found_out = true;
2
            }
3
            if let crate::props::property::CssProperty::Animation(v) = prop {
1
                let list = v.get_property().cloned().unwrap_or_default();
1
                let a = &list.as_ref()[0];
1
                assert_eq!(a.name.as_str(), "all");
1
                assert_eq!(
                    a.duration,
1
                    crate::props::basic::time::CssDuration::from_millis(2000)
                );
1
                assert_eq!(
                    a.timing,
                    crate::props::basic::animation::AnimationTiming::EaseOut
                );
1
                found_all = true;
2
            }
        }
1
        assert!(found_out && found_all);
1
    }
}