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)]
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
42
    fn from(p: azul_simplecss::ErrorPos) -> Self {
29
42
        CssSyntaxErrorPos { row: p.row, col: p.col }
30
42
    }
31
}
32

            
33
/// FFI-safe wrapper for invalid advance details in CSS syntax errors.
34
#[derive(Debug, Clone, Copy, PartialEq)]
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)]
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
42
    fn from(e: SimplecssError) -> Self {
54
42
        match e {
55
14
            SimplecssError::UnexpectedEndOfStream(pos) => CssSyntaxError::UnexpectedEndOfStream(pos.into()),
56
            SimplecssError::InvalidAdvance { expected, total, pos } => CssSyntaxError::InvalidAdvance(CssSyntaxInvalidAdvance { expected, total, pos: pos.into() }),
57
            SimplecssError::UnsupportedToken(pos) => CssSyntaxError::UnsupportedToken(pos.into()),
58
28
            SimplecssError::UnknownToken(pos) => CssSyntaxError::UnknownToken(pos.into()),
59
        }
60
42
    }
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<'a> CssParseError<'a> {
102
    pub fn to_contained(&self) -> CssParseErrorOwned {
103
        CssParseErrorOwned {
104
            css_string: self.css_string.to_string().into(),
105
            error: self.error.to_contained(),
106
            location: self.location,
107
        }
108
    }
109
}
110

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

            
121
impl<'a> CssParseError<'a> {
122
    /// Returns the string between the (start, end) location
123
    pub fn get_error_string(&self) -> &'a str {
124
        let (start, end) = (self.location.start.original_pos, self.location.end.original_pos);
125
        let s = &self.css_string[start..end];
126
        s.trim()
127
    }
128
}
129

            
130
#[derive(Debug, Clone, PartialEq)]
131
pub enum CssParseErrorInner<'a> {
132
    /// A hard error in the CSS syntax
133
    ParseError(CssSyntaxError),
134
    /// Braces are not balanced properly
135
    UnclosedBlock,
136
    /// Invalid syntax, such as `#div { #div: "my-value" }`
137
    MalformedCss,
138
    /// Error parsing dynamic CSS property, such as
139
    /// `#div { width: {{ my_id }} /* no default case */ }`
140
    DynamicCssParseError(DynamicCssParseError<'a>),
141
    /// Error while parsing a pseudo selector (like `:aldkfja`)
142
    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
143
    /// The path has to be either `*`, `div`, `p` or something like that
144
    NodeTypeTag(NodeTypeTagParseError<'a>),
145
    /// A certain property has an unknown key, for example: `alsdfkj: 500px` = `unknown CSS key
146
    /// "alsdfkj: 500px"`
147
    UnknownPropertyKey(&'a str, &'a str),
148
    /// `var()` can't be used on properties that expand to multiple values, since they would be
149
    /// ambiguous and degrade performance - for example `margin: var(--blah)` would be ambiguous
150
    /// because it's not clear when setting the variable, whether all sides should be set,
151
    /// instead, you have to use `margin-top: var(--blah)`, `margin-bottom: var(--baz)` in order
152
    /// to work around this limitation.
153
    VarOnShorthandProperty {
154
        key: CombinedCssPropertyType,
155
        value: &'a str,
156
    },
157
}
158

            
159
/// Wrapper for UnknownPropertyKey error.
160
#[derive(Debug, Clone, PartialEq)]
161
#[repr(C)]
162
pub struct UnknownPropertyKeyError {
163
    pub key: AzString,
164
    pub value: AzString,
165
}
166

            
167
/// Wrapper for VarOnShorthandProperty error.
168
#[derive(Debug, Clone, PartialEq)]
169
#[repr(C)]
170
pub struct VarOnShorthandPropertyError {
171
    pub key: CombinedCssPropertyType,
172
    pub value: AzString,
173
}
174

            
175
#[derive(Debug, Clone, PartialEq)]
176
#[repr(C, u8)]
177
pub enum CssParseErrorInnerOwned {
178
    ParseError(CssSyntaxError),
179
    UnclosedBlock,
180
    MalformedCss,
181
    DynamicCssParseError(DynamicCssParseErrorOwned),
182
    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
183
    NodeTypeTag(NodeTypeTagParseErrorOwned),
184
    UnknownPropertyKey(UnknownPropertyKeyError),
185
    VarOnShorthandProperty(VarOnShorthandPropertyError),
186
}
187

            
188
impl<'a> CssParseErrorInner<'a> {
189
    pub fn to_contained(&self) -> CssParseErrorInnerOwned {
190
        match self {
191
            CssParseErrorInner::ParseError(e) => CssParseErrorInnerOwned::ParseError(*e),
192
            CssParseErrorInner::UnclosedBlock => CssParseErrorInnerOwned::UnclosedBlock,
193
            CssParseErrorInner::MalformedCss => CssParseErrorInnerOwned::MalformedCss,
194
            CssParseErrorInner::DynamicCssParseError(e) => {
195
                CssParseErrorInnerOwned::DynamicCssParseError(e.to_contained())
196
            }
197
            CssParseErrorInner::PseudoSelectorParseError(e) => {
198
                CssParseErrorInnerOwned::PseudoSelectorParseError(e.to_contained())
199
            }
200
            CssParseErrorInner::NodeTypeTag(e) => {
201
                CssParseErrorInnerOwned::NodeTypeTag(e.to_contained())
202
            }
203
            CssParseErrorInner::UnknownPropertyKey(a, b) => {
204
                CssParseErrorInnerOwned::UnknownPropertyKey(UnknownPropertyKeyError { key: a.to_string().into(), value: b.to_string().into() })
205
            }
206
            CssParseErrorInner::VarOnShorthandProperty { key, value } => {
207
                CssParseErrorInnerOwned::VarOnShorthandProperty(VarOnShorthandPropertyError {
208
                    key: *key,
209
                    value: value.to_string().into(),
210
                })
211
            }
212
        }
213
    }
214
}
215

            
216
impl CssParseErrorInnerOwned {
217
    pub fn to_shared<'a>(&'a self) -> CssParseErrorInner<'a> {
218
        match self {
219
            CssParseErrorInnerOwned::ParseError(e) => CssParseErrorInner::ParseError(*e),
220
            CssParseErrorInnerOwned::UnclosedBlock => CssParseErrorInner::UnclosedBlock,
221
            CssParseErrorInnerOwned::MalformedCss => CssParseErrorInner::MalformedCss,
222
            CssParseErrorInnerOwned::DynamicCssParseError(e) => {
223
                CssParseErrorInner::DynamicCssParseError(e.to_shared())
224
            }
225
            CssParseErrorInnerOwned::PseudoSelectorParseError(e) => {
226
                CssParseErrorInner::PseudoSelectorParseError(e.to_shared())
227
            }
228
            CssParseErrorInnerOwned::NodeTypeTag(e) => {
229
                CssParseErrorInner::NodeTypeTag(e.to_shared())
230
            }
231
            CssParseErrorInnerOwned::UnknownPropertyKey(e) => {
232
                CssParseErrorInner::UnknownPropertyKey(e.key.as_str(), e.value.as_str())
233
            }
234
            CssParseErrorInnerOwned::VarOnShorthandProperty(e) => {
235
                CssParseErrorInner::VarOnShorthandProperty {
236
                    key: e.key,
237
                    value: e.value.as_str(),
238
                }
239
            }
240
        }
241
    }
242
}
243

            
244
impl_display! { CssParseErrorInner<'a>, {
245
    ParseError(e) => format!("Parse Error: {:?}", e),
246
    UnclosedBlock => "Unclosed block",
247
    MalformedCss => "Malformed Css",
248
    DynamicCssParseError(e) => format!("{}", e),
249
    PseudoSelectorParseError(e) => format!("Failed to parse pseudo-selector: {}", e),
250
    NodeTypeTag(e) => format!("Failed to parse CSS selector path: {}", e),
251
    UnknownPropertyKey(k, v) => format!("Unknown CSS key: \"{}: {}\"", k, v),
252
    VarOnShorthandProperty { key, value } => format!(
253
        "Error while parsing: \"{}: {};\": var() cannot be used on shorthand properties - use `{}-top` or `{}-x` as the key instead: ",
254
        key, value, key, key
255
    ),
256
}}
257

            
258
impl<'a> From<CssSyntaxError> for CssParseErrorInner<'a> {
259
    fn from(e: CssSyntaxError) -> Self {
260
        CssParseErrorInner::ParseError(e)
261
    }
262
}
263

            
264
impl<'a> From<SimplecssError> for CssParseErrorInner<'a> {
265
42
    fn from(e: SimplecssError) -> Self {
266
42
        CssParseErrorInner::ParseError(CssSyntaxError::from(e))
267
42
    }
268
}
269

            
270
impl_from! { DynamicCssParseError<'a>, CssParseErrorInner::DynamicCssParseError }
271
impl_from! { NodeTypeTagParseError<'a>, CssParseErrorInner::NodeTypeTag }
272
impl_from! { CssPseudoSelectorParseError<'a>, CssParseErrorInner::PseudoSelectorParseError }
273

            
274
#[derive(Debug, Clone, PartialEq, Eq)]
275
pub enum CssPseudoSelectorParseError<'a> {
276
    EmptyNthChild,
277
    UnknownSelector(&'a str, Option<&'a str>),
278
    InvalidNthChildPattern(&'a str),
279
    InvalidNthChild(ParseIntError),
280
}
281

            
282
impl<'a> From<ParseIntError> for CssPseudoSelectorParseError<'a> {
283
    fn from(e: ParseIntError) -> Self {
284
        CssPseudoSelectorParseError::InvalidNthChild(e)
285
    }
286
}
287

            
288
impl_display! { CssPseudoSelectorParseError<'a>, {
289
    EmptyNthChild => format!("\
290
        Empty :nth-child() selector - nth-child() must at least take a number, \
291
        a pattern (such as \"2n+3\") or the values \"even\" or \"odd\"."
292
    ),
293
    UnknownSelector(selector, value) => {
294
        let format_str = match value {
295
            Some(v) => format!("{}({})", selector, v),
296
            None => selector.to_string(),
297
        };
298
        format!("Invalid or unknown CSS pseudo-selector: ':{}'", format_str)
299
    },
300
    InvalidNthChildPattern(selector) => format!(
301
        "Invalid pseudo-selector :{} - value has to be a \
302
        number, \"even\" or \"odd\" or a pattern such as \"2n+3\"", selector
303
    ),
304
    InvalidNthChild(e) => format!("Invalid :nth-child pseudo-selector: ':{}'", e),
305
}}
306

            
307
/// Wrapper for UnknownSelector error.
308
#[derive(Debug, Clone, PartialEq)]
309
#[repr(C)]
310
pub struct UnknownSelectorError {
311
    pub selector: AzString,
312
    pub suggestion: OptionString,
313
}
314

            
315
#[derive(Debug, Clone, PartialEq)]
316
#[repr(C, u8)]
317
pub enum CssPseudoSelectorParseErrorOwned {
318
    EmptyNthChild,
319
    UnknownSelector(UnknownSelectorError),
320
    InvalidNthChildPattern(AzString),
321
    InvalidNthChild(crate::props::basic::error::ParseIntError),
322
}
323

            
324
impl<'a> CssPseudoSelectorParseError<'a> {
325
    pub fn to_contained(&self) -> CssPseudoSelectorParseErrorOwned {
326
        match self {
327
            CssPseudoSelectorParseError::EmptyNthChild => {
328
                CssPseudoSelectorParseErrorOwned::EmptyNthChild
329
            }
330
            CssPseudoSelectorParseError::UnknownSelector(a, b) => {
331
                CssPseudoSelectorParseErrorOwned::UnknownSelector(UnknownSelectorError {
332
                    selector: a.to_string().into(),
333
                    suggestion: b.map(|s| AzString::from(s.to_string())).into(),
334
                })
335
            }
336
            CssPseudoSelectorParseError::InvalidNthChildPattern(s) => {
337
                CssPseudoSelectorParseErrorOwned::InvalidNthChildPattern(s.to_string().into())
338
            }
339
            CssPseudoSelectorParseError::InvalidNthChild(e) => {
340
                CssPseudoSelectorParseErrorOwned::InvalidNthChild(e.clone().into())
341
            }
342
        }
343
    }
344
}
345

            
346
impl CssPseudoSelectorParseErrorOwned {
347
    pub fn to_shared<'a>(&'a self) -> CssPseudoSelectorParseError<'a> {
348
        match self {
349
            CssPseudoSelectorParseErrorOwned::EmptyNthChild => {
350
                CssPseudoSelectorParseError::EmptyNthChild
351
            }
352
            CssPseudoSelectorParseErrorOwned::UnknownSelector(e) => {
353
                CssPseudoSelectorParseError::UnknownSelector(e.selector.as_str(), e.suggestion.as_ref().map(|s| s.as_str()))
354
            }
355
            CssPseudoSelectorParseErrorOwned::InvalidNthChildPattern(s) => {
356
                CssPseudoSelectorParseError::InvalidNthChildPattern(s)
357
            }
358
            CssPseudoSelectorParseErrorOwned::InvalidNthChild(e) => {
359
                CssPseudoSelectorParseError::InvalidNthChild(e.to_std())
360
            }
361
        }
362
    }
363
}
364

            
365
/// Error that can happen during `css_parser::parse_key_value_pair`
366
#[derive(Debug, Clone, PartialEq)]
367
pub enum DynamicCssParseError<'a> {
368
    /// The brace contents aren't valid, i.e. `var(asdlfkjasf)`
369
    InvalidBraceContents(&'a str),
370
    /// Unexpected value when parsing the string
371
    UnexpectedValue(CssParsingError<'a>),
372
}
373

            
374
impl_display! { DynamicCssParseError<'a>, {
375
    InvalidBraceContents(e) => format!("Invalid contents of var() function: var({})", e),
376
    UnexpectedValue(e) => format!("{}", e),
377
}}
378

            
379
impl<'a> From<CssParsingError<'a>> for DynamicCssParseError<'a> {
380
28
    fn from(e: CssParsingError<'a>) -> Self {
381
28
        DynamicCssParseError::UnexpectedValue(e)
382
28
    }
383
}
384

            
385
#[derive(Debug, Clone, PartialEq)]
386
#[repr(C, u8)]
387
pub enum DynamicCssParseErrorOwned {
388
    InvalidBraceContents(AzString),
389
    UnexpectedValue(CssParsingErrorOwned),
390
}
391

            
392
impl<'a> DynamicCssParseError<'a> {
393
    pub fn to_contained(&self) -> DynamicCssParseErrorOwned {
394
        match self {
395
            DynamicCssParseError::InvalidBraceContents(s) => {
396
                DynamicCssParseErrorOwned::InvalidBraceContents(s.to_string().into())
397
            }
398
            DynamicCssParseError::UnexpectedValue(e) => {
399
                DynamicCssParseErrorOwned::UnexpectedValue(e.to_contained())
400
            }
401
        }
402
    }
403
}
404

            
405
impl DynamicCssParseErrorOwned {
406
    pub fn to_shared<'a>(&'a self) -> DynamicCssParseError<'a> {
407
        match self {
408
            DynamicCssParseErrorOwned::InvalidBraceContents(s) => {
409
                DynamicCssParseError::InvalidBraceContents(s)
410
            }
411
            DynamicCssParseErrorOwned::UnexpectedValue(e) => {
412
                DynamicCssParseError::UnexpectedValue(e.to_shared())
413
            }
414
        }
415
    }
416
}
417

            
418
/// "selector" contains the actual selector such as "nth-child" while "value" contains
419
/// an optional value - for example "nth-child(3)" would be: selector: "nth-child", value: "3".
420
154
pub fn pseudo_selector_from_str<'a>(
421
154
    selector: &'a str,
422
154
    value: Option<&'a str>,
423
154
) -> Result<CssPathPseudoSelector, CssPseudoSelectorParseError<'a>> {
424
154
    match selector {
425
154
        "first" => Ok(CssPathPseudoSelector::First),
426
154
        "last" => Ok(CssPathPseudoSelector::Last),
427
154
        "hover" => Ok(CssPathPseudoSelector::Hover),
428
119
        "active" => Ok(CssPathPseudoSelector::Active),
429
112
        "focus" => Ok(CssPathPseudoSelector::Focus),
430
98
        "dragging" => Ok(CssPathPseudoSelector::Dragging),
431
98
        "drag-over" => Ok(CssPathPseudoSelector::DragOver),
432
98
        "nth-child" => {
433
            let value = value.ok_or(CssPseudoSelectorParseError::EmptyNthChild)?;
434
            let parsed = parse_nth_child_selector(value)?;
435
            Ok(CssPathPseudoSelector::NthChild(parsed))
436
        }
437
98
        "lang" => {
438
63
            let lang_value = value.ok_or(CssPseudoSelectorParseError::UnknownSelector(
439
63
                selector, value,
440
63
            ))?;
441
            // Remove quotes if present
442
63
            let lang_value = lang_value
443
63
                .trim()
444
63
                .trim_start_matches('"')
445
63
                .trim_end_matches('"')
446
63
                .trim_start_matches('\'')
447
63
                .trim_end_matches('\'')
448
63
                .trim();
449
63
            Ok(CssPathPseudoSelector::Lang(AzString::from(
450
63
                lang_value.to_string(),
451
63
            )))
452
        }
453
35
        _ => Err(CssPseudoSelectorParseError::UnknownSelector(
454
35
            selector, value,
455
35
        )),
456
    }
457
154
}
458

            
459
/// Parses the inner content of an attribute selector token (the text between `[` and `]`).
460
///
461
/// Returns `None` if the input is malformed (empty name, unterminated quote, etc).
462
161
pub fn parse_attribute_selector(input: &str) -> Option<CssAttributeSelector> {
463
161
    let s = input.trim();
464
161
    if s.is_empty() {
465
14
        return None;
466
147
    }
467

            
468
    // Find the operator (the longest match wins).
469
147
    let (op, op_pos): (AttributeMatchOp, Option<usize>) = if let Some(i) = s.find("~=") {
470
14
        (AttributeMatchOp::Includes, Some(i))
471
133
    } else if let Some(i) = s.find("|=") {
472
14
        (AttributeMatchOp::DashMatch, Some(i))
473
119
    } else if let Some(i) = s.find("^=") {
474
14
        (AttributeMatchOp::Prefix, Some(i))
475
105
    } else if let Some(i) = s.find("$=") {
476
14
        (AttributeMatchOp::Suffix, Some(i))
477
91
    } else if let Some(i) = s.find("*=") {
478
14
        (AttributeMatchOp::Substring, Some(i))
479
77
    } else if let Some(i) = s.find('=') {
480
56
        (AttributeMatchOp::Eq, Some(i))
481
    } else {
482
21
        (AttributeMatchOp::Exists, None)
483
    };
484

            
485
147
    let (name, value) = match op_pos {
486
21
        None => (s, None),
487
126
        Some(i) => {
488
126
            let name = s[..i].trim();
489
126
            let op_len = if matches!(op, AttributeMatchOp::Eq) { 1 } else { 2 };
490
126
            let raw_value = s[i + op_len..].trim();
491
126
            let unquoted = strip_attribute_quotes(raw_value)?;
492
112
            (name, Some(unquoted))
493
        }
494
    };
495

            
496
133
    if name.is_empty() {
497
        return None;
498
133
    }
499
    // Reject names that contain whitespace or quotes.
500
805
    if name.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') {
501
        return None;
502
133
    }
503

            
504
    Some(CssAttributeSelector {
505
133
        name: name.to_string().into(),
506
133
        op,
507
133
        value: match value {
508
112
            Some(v) => OptionString::Some(v.to_string().into()),
509
21
            None => OptionString::None,
510
        },
511
    })
512
161
}
513

            
514
/// Strips matching surrounding `"` or `'` from a value. If the value is unquoted,
515
/// returns it unchanged. Returns `None` if quoting is unbalanced.
516
126
fn strip_attribute_quotes(s: &str) -> Option<&str> {
517
126
    let bytes = s.as_bytes();
518
126
    if bytes.len() >= 2 {
519
126
        let first = bytes[0];
520
126
        let last = bytes[bytes.len() - 1];
521
126
        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
522
105
            return Some(&s[1..s.len() - 1]);
523
21
        }
524
21
        if first == b'"' || first == b'\'' || last == b'"' || last == b'\'' {
525
            // Unbalanced quote.
526
14
            return None;
527
7
        }
528
    } else if bytes.len() == 1 && (bytes[0] == b'"' || bytes[0] == b'\'') {
529
        return None;
530
    }
531
7
    Some(s)
532
126
}
533

            
534
/// Parses the inner value of the `:nth-child` selector, including numbers and patterns.
535
///
536
/// I.e.: `"2n+3"` -> `Pattern { repeat: 2, offset: 3 }`
537
fn parse_nth_child_selector<'a>(
538
    value: &'a str,
539
) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'a>> {
540
    let value = value.trim();
541

            
542
    if value.is_empty() {
543
        return Err(CssPseudoSelectorParseError::EmptyNthChild);
544
    }
545

            
546
    if let Ok(number) = value.parse::<u32>() {
547
        return Ok(CssNthChildSelector::Number(number));
548
    }
549

            
550
    // If the value is not a number
551
    match value {
552
        "even" => Ok(CssNthChildSelector::Even),
553
        "odd" => Ok(CssNthChildSelector::Odd),
554
        _ => parse_nth_child_pattern(value),
555
    }
556
}
557

            
558
/// Parses the pattern between the braces of a "nth-child" (such as "2n+3").
559
fn parse_nth_child_pattern<'a>(
560
    value: &'a str,
561
) -> Result<CssNthChildSelector, CssPseudoSelectorParseError<'a>> {
562
    use crate::css::CssNthChildPattern;
563

            
564
    let value = value.trim();
565

            
566
    if value.is_empty() {
567
        return Err(CssPseudoSelectorParseError::EmptyNthChild);
568
    }
569

            
570
    // TODO: Test for "+"
571
    let repeat = value
572
        .split("n")
573
        .next()
574
        .ok_or(CssPseudoSelectorParseError::InvalidNthChildPattern(value))?
575
        .trim()
576
        .parse::<u32>()?;
577

            
578
    // In a "2n+3" form, the first .next() yields the "2n", the second .next() yields the "3"
579
    let mut offset_iterator = value.split("+");
580

            
581
    // has to succeed, since the string is verified to not be empty
582
    offset_iterator.next().unwrap();
583

            
584
    let offset = match offset_iterator.next() {
585
        Some(offset_string) => {
586
            let offset_string = offset_string.trim();
587
            if offset_string.is_empty() {
588
                return Err(CssPseudoSelectorParseError::InvalidNthChildPattern(value));
589
            } else {
590
                offset_string.parse::<u32>()?
591
            }
592
        }
593
        None => 0,
594
    };
595

            
596
    Ok(CssNthChildSelector::Pattern(CssNthChildPattern {
597
        pattern_repeat: repeat,
598
        offset,
599
    }))
600
}
601

            
602
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
603
#[repr(C)]
604
pub struct ErrorLocation {
605
    pub original_pos: usize,
606
}
607

            
608
/// FFI-safe replacement for `(ErrorLocation, ErrorLocation)` tuple.
609
/// Represents a range (start..end) in the source text.
610
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
611
#[repr(C)]
612
pub struct ErrorLocationRange {
613
    pub start: ErrorLocation,
614
    pub end: ErrorLocation,
615
}
616

            
617
impl ErrorLocation {
618
    /// Given an error location, returns the (line, column)
619
    pub fn get_line_column_from_error(&self, css_string: &str) -> (usize, usize) {
620
        let error_location = self.original_pos.saturating_sub(1);
621
        let (mut line_number, mut total_characters) = (0, 0);
622

            
623
        for line in css_string[0..error_location].lines() {
624
            line_number += 1;
625
            total_characters += line.chars().count();
626
        }
627

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

            
632
        (line_number, column_pos)
633
    }
634
}
635

            
636
impl<'a> fmt::Display for CssParseError<'a> {
637
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
638
        let start_location = self.location.start.get_line_column_from_error(self.css_string);
639
        let end_location = self.location.end.get_line_column_from_error(self.css_string);
640
        write!(
641
            f,
642
            "    start: line {}:{}\r\n    end: line {}:{}\r\n    text: \"{}\"\r\n    reason: {}",
643
            start_location.0,
644
            start_location.1,
645
            end_location.0,
646
            end_location.1,
647
            self.get_error_string(),
648
            self.error,
649
        )
650
    }
651
}
652

            
653
/// Parses a CSS string into a [`Css`] value and a list of recoverable warnings.
654
///
655
/// Never panics. Syntax errors and unsupported properties are collected as
656
/// [`CssParseWarnMsg`] items rather than causing a hard failure, so the caller
657
/// always receives a (possibly empty) stylesheet.
658
7611
pub fn new_from_str<'a>(css_string: &'a str) -> (Css, Vec<CssParseWarnMsg<'a>>) {
659
7611
    let mut tokenizer = Tokenizer::new(css_string);
660
7611
    let (rules, warnings) = match new_from_str_inner(css_string, &mut tokenizer) {
661
7611
        Ok((rules, warnings)) => (rules, warnings),
662
        Err(error) => {
663
            let warning = CssParseWarnMsg {
664
                warning: CssParseWarnMsgInner::ParseError(error.error),
665
                location: error.location,
666
            };
667
            (Vec::<CssRuleBlock>::new(), vec![warning])
668
        }
669
    };
670

            
671
7611
    (
672
7611
        Css { rules: rules.into() },
673
7611
        warnings,
674
7611
    )
675
7611
}
676

            
677
/// Returns the location of where the parser is currently in the document
678
119988
fn get_error_location(tokenizer: &Tokenizer) -> ErrorLocation {
679
119988
    ErrorLocation {
680
119988
        original_pos: tokenizer.pos(),
681
119988
    }
682
119988
}
683

            
684
#[derive(Debug, Clone, PartialEq)]
685
pub enum CssPathParseError<'a> {
686
    EmptyPath,
687
    /// Invalid item encountered in string (for example a "{", "}")
688
    InvalidTokenEncountered(&'a str),
689
    UnexpectedEndOfStream(&'a str),
690
    SyntaxError(CssSyntaxError),
691
    /// The path has to be either `*`, `div`, `p` or something like that
692
    NodeTypeTag(NodeTypeTagParseError<'a>),
693
    /// Error while parsing a pseudo selector (like `:aldkfja`)
694
    PseudoSelectorParseError(CssPseudoSelectorParseError<'a>),
695
}
696

            
697
impl_from! { NodeTypeTagParseError<'a>, CssPathParseError::NodeTypeTag }
698
impl_from! { CssPseudoSelectorParseError<'a>, CssPathParseError::PseudoSelectorParseError }
699

            
700
impl<'a> From<CssSyntaxError> for CssPathParseError<'a> {
701
    fn from(e: CssSyntaxError) -> Self {
702
        CssPathParseError::SyntaxError(e)
703
    }
704
}
705

            
706
impl<'a> From<SimplecssError> for CssPathParseError<'a> {
707
    fn from(e: SimplecssError) -> Self {
708
        CssPathParseError::SyntaxError(CssSyntaxError::from(e))
709
    }
710
}
711

            
712
#[derive(Debug, Clone, PartialEq)]
713
pub enum CssPathParseErrorOwned {
714
    EmptyPath,
715
    InvalidTokenEncountered(AzString),
716
    UnexpectedEndOfStream(AzString),
717
    SyntaxError(CssSyntaxError),
718
    NodeTypeTag(NodeTypeTagParseErrorOwned),
719
    PseudoSelectorParseError(CssPseudoSelectorParseErrorOwned),
720
}
721

            
722
impl<'a> CssPathParseError<'a> {
723
    pub fn to_contained(&self) -> CssPathParseErrorOwned {
724
        match self {
725
            CssPathParseError::EmptyPath => CssPathParseErrorOwned::EmptyPath,
726
            CssPathParseError::InvalidTokenEncountered(s) => {
727
                CssPathParseErrorOwned::InvalidTokenEncountered(s.to_string().into())
728
            }
729
            CssPathParseError::UnexpectedEndOfStream(s) => {
730
                CssPathParseErrorOwned::UnexpectedEndOfStream(s.to_string().into())
731
            }
732
            CssPathParseError::SyntaxError(e) => CssPathParseErrorOwned::SyntaxError(*e),
733
            CssPathParseError::NodeTypeTag(e) => {
734
                CssPathParseErrorOwned::NodeTypeTag(e.to_contained())
735
            }
736
            CssPathParseError::PseudoSelectorParseError(e) => {
737
                CssPathParseErrorOwned::PseudoSelectorParseError(e.to_contained())
738
            }
739
        }
740
    }
741
}
742

            
743
impl CssPathParseErrorOwned {
744
    pub fn to_shared<'a>(&'a self) -> CssPathParseError<'a> {
745
        match self {
746
            CssPathParseErrorOwned::EmptyPath => CssPathParseError::EmptyPath,
747
            CssPathParseErrorOwned::InvalidTokenEncountered(s) => {
748
                CssPathParseError::InvalidTokenEncountered(s)
749
            }
750
            CssPathParseErrorOwned::UnexpectedEndOfStream(s) => {
751
                CssPathParseError::UnexpectedEndOfStream(s)
752
            }
753
            CssPathParseErrorOwned::SyntaxError(e) => CssPathParseError::SyntaxError(*e),
754
            CssPathParseErrorOwned::NodeTypeTag(e) => CssPathParseError::NodeTypeTag(e.to_shared()),
755
            CssPathParseErrorOwned::PseudoSelectorParseError(e) => {
756
                CssPathParseError::PseudoSelectorParseError(e.to_shared())
757
            }
758
        }
759
    }
760
}
761

            
762
/// Parses a CSS path from a string (only the path,.no commas allowed)
763
///
764
/// ```rust
765
/// # extern crate azul_css;
766
/// # use azul_css::parser2::parse_css_path;
767
/// # use azul_css::css::{
768
/// #     CssPathSelector::*, CssPathPseudoSelector::*, CssPath,
769
/// #     NodeTypeTag::*, CssNthChildSelector::*
770
/// # };
771
///
772
/// assert_eq!(
773
///     parse_css_path("* div #my_id > .class:nth-child(2)"),
774
///     Ok(CssPath {
775
///         selectors: vec![
776
///             Global,
777
///             Type(Div),
778
///             Children,
779
///             Id("my_id".to_string().into()),
780
///             DirectChildren,
781
///             Class("class".to_string().into()),
782
///             PseudoSelector(NthChild(Number(2))),
783
///         ]
784
///         .into()
785
///     })
786
/// );
787
/// ```
788
pub fn parse_css_path<'a>(input: &'a str) -> Result<CssPath, CssPathParseError<'a>> {
789
    use azul_simplecss::{Combinator, Token};
790

            
791
    let input = input.trim();
792
    if input.is_empty() {
793
        return Err(CssPathParseError::EmptyPath);
794
    }
795

            
796
    let mut tokenizer = Tokenizer::new(input);
797
    let mut selectors = Vec::new();
798

            
799
    loop {
800
        let token = tokenizer.parse_next()?;
801
        match token {
802
            Token::UniversalSelector => {
803
                selectors.push(CssPathSelector::Global);
804
            }
805
            Token::TypeSelector(div_type) => {
806
                if let Ok(nt) = NodeTypeTag::from_str(div_type) {
807
                    selectors.push(CssPathSelector::Type(nt));
808
                }
809
            }
810
            Token::IdSelector(id) => {
811
                selectors.push(CssPathSelector::Id(id.to_string().into()));
812
            }
813
            Token::ClassSelector(class) => {
814
                selectors.push(CssPathSelector::Class(class.to_string().into()));
815
            }
816
            Token::Combinator(Combinator::GreaterThan) => {
817
                selectors.push(CssPathSelector::DirectChildren);
818
            }
819
            Token::Combinator(Combinator::Space) => {
820
                selectors.push(CssPathSelector::Children);
821
            }
822
            Token::Combinator(Combinator::Plus) => {
823
                selectors.push(CssPathSelector::AdjacentSibling);
824
            }
825
            Token::Combinator(Combinator::Tilde) => {
826
                selectors.push(CssPathSelector::GeneralSibling);
827
            }
828
            Token::PseudoClass { selector, value } => {
829
                selectors.push(CssPathSelector::PseudoSelector(pseudo_selector_from_str(
830
                    selector, value,
831
                )?));
832
            }
833
            Token::EndOfStream => {
834
                break;
835
            }
836
            _ => {
837
                return Err(CssPathParseError::InvalidTokenEncountered(input));
838
            }
839
        }
840
    }
841

            
842
    if !selectors.is_empty() {
843
        Ok(CssPath {
844
            selectors: selectors.into(),
845
        })
846
    } else {
847
        Err(CssPathParseError::EmptyPath)
848
    }
849
}
850

            
851
#[derive(Debug, Clone, PartialEq)]
852
pub struct UnparsedCssRuleBlock<'a> {
853
    /// The css path (full selector) of the style ruleset
854
    pub path: CssPath,
855
    /// `"justify-content" => "center"`
856
    pub declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
857
    /// Conditions from enclosing @-rules (@media, @lang, etc.)
858
    pub conditions: Vec<DynamicSelector>,
859
}
860

            
861
/// Owned version of UnparsedCssRuleBlock, with BTreeMap of Strings.
862
#[derive(Debug, Clone, PartialEq)]
863
pub struct UnparsedCssRuleBlockOwned {
864
    pub path: CssPath,
865
    pub declarations: BTreeMap<String, (String, ErrorLocationRange)>,
866
    pub conditions: Vec<DynamicSelector>,
867
}
868

            
869
impl<'a> UnparsedCssRuleBlock<'a> {
870
    pub fn to_contained(&self) -> UnparsedCssRuleBlockOwned {
871
        UnparsedCssRuleBlockOwned {
872
            path: self.path.clone(),
873
            declarations: self
874
                .declarations
875
                .iter()
876
                .map(|(k, (v, loc))| (k.to_string(), (v.to_string(), *loc)))
877
                .collect(),
878
            conditions: self.conditions.clone(),
879
        }
880
    }
881
}
882

            
883
impl UnparsedCssRuleBlockOwned {
884
    pub fn to_shared<'a>(&'a self) -> UnparsedCssRuleBlock<'a> {
885
        UnparsedCssRuleBlock {
886
            path: self.path.clone(),
887
            declarations: self
888
                .declarations
889
                .iter()
890
                .map(|(k, (v, loc))| (k.as_str(), (v.as_str(), *loc)))
891
                .collect(),
892
            conditions: self.conditions.clone(),
893
        }
894
    }
895
}
896

            
897
#[derive(Debug, Clone, PartialEq)]
898
pub struct CssParseWarnMsg<'a> {
899
    pub warning: CssParseWarnMsgInner<'a>,
900
    pub location: ErrorLocationRange,
901
}
902

            
903
/// Owned version of CssParseWarnMsg, where warning is the owned type.
904
#[derive(Debug, Clone, PartialEq)]
905
pub struct CssParseWarnMsgOwned {
906
    pub warning: CssParseWarnMsgInnerOwned,
907
    pub location: ErrorLocationRange,
908
}
909

            
910
impl<'a> CssParseWarnMsg<'a> {
911
    pub fn to_contained(&self) -> CssParseWarnMsgOwned {
912
        CssParseWarnMsgOwned {
913
            warning: self.warning.to_contained(),
914
            location: self.location,
915
        }
916
    }
917
}
918

            
919
impl CssParseWarnMsgOwned {
920
    pub fn to_shared<'a>(&'a self) -> CssParseWarnMsg<'a> {
921
        CssParseWarnMsg {
922
            warning: self.warning.to_shared(),
923
            location: self.location,
924
        }
925
    }
926
}
927

            
928
#[derive(Debug, Clone, PartialEq)]
929
pub enum CssParseWarnMsgInner<'a> {
930
    /// Key "blah" isn't (yet) supported, so the parser didn't attempt to parse the value at all
931
    UnsupportedKeyValuePair { key: &'a str, value: &'a str },
932
    /// A CSS parse error that was encountered but recovered from
933
    ParseError(CssParseErrorInner<'a>),
934
    /// A rule was skipped due to an error
935
    SkippedRule {
936
        selector: Option<&'a str>,
937
        error: CssParseErrorInner<'a>,
938
    },
939
    /// A declaration was skipped due to an error
940
    SkippedDeclaration {
941
        key: &'a str,
942
        value: &'a str,
943
        error: CssParseErrorInner<'a>,
944
    },
945
    /// Malformed block structure (mismatched braces, etc.)
946
    MalformedStructure { message: &'a str },
947
}
948

            
949
#[derive(Debug, Clone, PartialEq)]
950
pub enum CssParseWarnMsgInnerOwned {
951
    UnsupportedKeyValuePair {
952
        key: String,
953
        value: String,
954
    },
955
    ParseError(CssParseErrorInnerOwned),
956
    SkippedRule {
957
        selector: Option<String>,
958
        error: CssParseErrorInnerOwned,
959
    },
960
    SkippedDeclaration {
961
        key: String,
962
        value: String,
963
        error: CssParseErrorInnerOwned,
964
    },
965
    MalformedStructure {
966
        message: String,
967
    },
968
}
969

            
970
impl<'a> CssParseWarnMsgInner<'a> {
971
    pub fn to_contained(&self) -> CssParseWarnMsgInnerOwned {
972
        match self {
973
            Self::UnsupportedKeyValuePair { key, value } => {
974
                CssParseWarnMsgInnerOwned::UnsupportedKeyValuePair {
975
                    key: key.to_string(),
976
                    value: value.to_string(),
977
                }
978
            }
979
            Self::ParseError(e) => CssParseWarnMsgInnerOwned::ParseError(e.to_contained()),
980
            Self::SkippedRule { selector, error } => CssParseWarnMsgInnerOwned::SkippedRule {
981
                selector: selector.map(|s| s.to_string()),
982
                error: error.to_contained(),
983
            },
984
            Self::SkippedDeclaration { key, value, error } => {
985
                CssParseWarnMsgInnerOwned::SkippedDeclaration {
986
                    key: key.to_string(),
987
                    value: value.to_string(),
988
                    error: error.to_contained(),
989
                }
990
            }
991
            Self::MalformedStructure { message } => CssParseWarnMsgInnerOwned::MalformedStructure {
992
                message: message.to_string(),
993
            },
994
        }
995
    }
996
}
997

            
998
impl CssParseWarnMsgInnerOwned {
999
    pub fn to_shared<'a>(&'a self) -> CssParseWarnMsgInner<'a> {
        match self {
            Self::UnsupportedKeyValuePair { key, value } => {
                CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value }
            }
            Self::ParseError(e) => CssParseWarnMsgInner::ParseError(e.to_shared()),
            Self::SkippedRule { selector, error } => CssParseWarnMsgInner::SkippedRule {
                selector: selector.as_deref(),
                error: error.to_shared(),
            },
            Self::SkippedDeclaration { key, value, error } => {
                CssParseWarnMsgInner::SkippedDeclaration {
                    key,
                    value,
                    error: error.to_shared(),
                }
            }
            Self::MalformedStructure { message } => {
                CssParseWarnMsgInner::MalformedStructure { message }
            }
        }
    }
}
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
112
fn parse_media_conditions(content: &str) -> Vec<DynamicSelector> {
112
    let mut conditions = Vec::new();
112
    let content = content.trim();
    // Handle simple media types: "screen", "print", "all"
112
    if content.eq_ignore_ascii_case("screen") {
42
        conditions.push(DynamicSelector::Media(MediaType::Screen));
42
        return conditions;
70
    }
70
    if content.eq_ignore_ascii_case("print") {
7
        conditions.push(DynamicSelector::Media(MediaType::Print));
7
        return conditions;
63
    }
63
    if content.eq_ignore_ascii_case("all") {
7
        conditions.push(DynamicSelector::Media(MediaType::All));
7
        return conditions;
56
    }
    // Parse more complex media queries like "(min-width: 800px)" or "screen and (max-width: 600px)"
    // Split by "and" for compound queries
70
    for part in content.split(" and ") {
70
        let part = part.trim();
        // Skip media type keywords in compound queries
70
        if part.eq_ignore_ascii_case("screen")
56
            || part.eq_ignore_ascii_case("print")
56
            || part.eq_ignore_ascii_case("all")
        {
14
            if part.eq_ignore_ascii_case("screen") {
14
                conditions.push(DynamicSelector::Media(MediaType::Screen));
14
            } 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));
            }
14
            continue;
56
        }
        // Parse parenthesized conditions like "(min-width: 800px)"
56
        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
56
            if let Some(selector) = parse_media_feature(inner) {
56
                conditions.push(selector);
56
            }
        }
    }
56
    conditions
112
}
/// Parses a single media feature like "min-width: 800px"
56
fn parse_media_feature(feature: &str) -> Option<DynamicSelector> {
56
    let parts: Vec<&str> = feature.splitn(2, ':').collect();
56
    if parts.len() != 2 {
        // Handle features without values like "orientation: portrait"
        return None;
56
    }
56
    let key = parts[0].trim();
56
    let value = parts[1].trim();
56
    match key.to_lowercase().as_str() {
56
        "min-width" => {
14
            if let Some(px) = parse_px_value(value) {
14
                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
14
                    Some(px),
14
                    None,
14
                )));
            }
        }
42
        "max-width" => {
14
            if let Some(px) = parse_px_value(value) {
14
                return Some(DynamicSelector::ViewportWidth(MinMaxRange::new(
14
                    None,
14
                    Some(px),
14
                )));
            }
        }
28
        "min-height" => {
7
            if let Some(px) = parse_px_value(value) {
7
                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
7
                    Some(px),
7
                    None,
7
                )));
            }
        }
21
        "max-height" => {
7
            if let Some(px) = parse_px_value(value) {
7
                return Some(DynamicSelector::ViewportHeight(MinMaxRange::new(
7
                    None,
7
                    Some(px),
7
                )));
            }
        }
14
        "orientation" => {
14
            if value.eq_ignore_ascii_case("portrait") {
7
                return Some(DynamicSelector::Orientation(OrientationType::Portrait));
7
            } else if value.eq_ignore_ascii_case("landscape") {
7
                return Some(DynamicSelector::Orientation(OrientationType::Landscape));
            }
        }
        "prefers-color-scheme" => {
            if value.eq_ignore_ascii_case("dark") {
                return Some(DynamicSelector::Theme(ThemeCondition::Dark));
            } else if value.eq_ignore_ascii_case("light") {
                return Some(DynamicSelector::Theme(ThemeCondition::Light));
            }
        }
        "prefers-reduced-motion" => {
            if value.eq_ignore_ascii_case("reduce") {
                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::True));
            } else if value.eq_ignore_ascii_case("no-preference") {
                return Some(DynamicSelector::PrefersReducedMotion(BoolCondition::False));
            }
        }
        "prefers-contrast" | "prefers-high-contrast" => {
            if value.eq_ignore_ascii_case("more") || value.eq_ignore_ascii_case("high") || value.eq_ignore_ascii_case("active") {
                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));
            }
        }
        "aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), Some(ratio))));
            }
        }
        "min-aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(Some(ratio), None)));
            }
        }
        "max-aspect-ratio" => {
            if let Some(ratio) = parse_ratio_value(value) {
                return Some(DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(ratio))));
            }
        }
        _ => {}
    }
    None
56
}
/// Parses a pixel value like "800px" and returns the numeric value
42
fn parse_px_value(value: &str) -> Option<f32> {
42
    let value = value.trim();
42
    if let Some(num_str) = value.strip_suffix("px") {
42
        num_str.trim().parse::<f32>().ok()
    } else {
        // Try parsing as a bare number
        value.parse::<f32>().ok()
    }
42
}
/// Parses a ratio value like "16/9" or "1.777" and returns it as f32
fn parse_ratio_value(value: &str) -> Option<f32> {
    let value = value.trim();
    if let Some((num, den)) = value.split_once('/') {
        let num: f32 = num.trim().parse().ok()?;
        let den: f32 = den.trim().parse().ok()?;
        if den == 0.0 { return None; }
        Some(num / den)
    } else {
        value.parse::<f32>().ok()
    }
}
/// Parses @container conditions from the content following "@container"
/// Format: @container (min-width: 400px) or @container sidebar (min-width: 400px)
fn parse_container_conditions(content: &str) -> Vec<DynamicSelector> {
    let mut conditions = Vec::new();
    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)"
    let (name_part, query_part) = if content.starts_with('(') {
        (None, content)
    } else if let Some(paren_idx) = content.find('(') {
        let name = content[..paren_idx].trim();
        if !name.is_empty() {
            (Some(name), &content[paren_idx..])
        } else {
            (None, content)
        }
    } else {
        // No parentheses - might be just a container name
        if !content.is_empty() {
            conditions.push(DynamicSelector::ContainerName(AzString::from(content.to_string())));
        }
        return conditions;
    };
    if let Some(name) = name_part {
        conditions.push(DynamicSelector::ContainerName(AzString::from(name.to_string())));
    }
    // Parse the parenthesized query parts
    for part in query_part.split(" and ") {
        let part = part.trim();
        if let Some(inner) = part.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
            if let Some(selector) = parse_container_feature(inner) {
                conditions.push(selector);
            }
        }
    }
    conditions
}
/// Parses a single container query feature like "min-width: 400px"
fn parse_container_feature(feature: &str) -> Option<DynamicSelector> {
    let (key, value) = feature.split_once(':')?;
    let key = key.trim();
    let value = value.trim();
    match key.to_lowercase().as_str() {
        "min-width" => {
            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(Some(px), None)))
        }
        "max-width" => {
            parse_px_value(value).map(|px| DynamicSelector::ContainerWidth(MinMaxRange::new(None, Some(px))))
        }
        "min-height" => {
            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(Some(px), None)))
        }
        "max-height" => {
            parse_px_value(value).map(|px| DynamicSelector::ContainerHeight(MinMaxRange::new(None, Some(px))))
        }
        "aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), Some(r))))
        }
        "min-aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(Some(r), None)))
        }
        "max-aspect-ratio" => {
            parse_ratio_value(value).map(|r| DynamicSelector::AspectRatio(MinMaxRange::new(None, Some(r))))
        }
        _ => None,
    }
}
/// Parses @theme condition from the content following "@theme"
/// Format: @theme(dark) or @theme dark
fn parse_theme_condition(content: &str) -> Option<DynamicSelector> {
    let content = content.trim();
    let inner = content
        .strip_prefix('(')
        .and_then(|s| s.strip_suffix(')'))
        .unwrap_or(content)
        .trim();
    let inner = inner
        .strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
        .unwrap_or(inner)
        .trim();
    match inner.to_lowercase().as_str() {
        "dark" => Some(DynamicSelector::Theme(ThemeCondition::Dark)),
        "light" => Some(DynamicSelector::Theme(ThemeCondition::Light)),
        _ => None,
    }
}
/// Parses @lang condition from the content following "@lang"
/// Format: @lang("de-DE") or @lang(de-DE)
fn parse_lang_condition(content: &str) -> Option<DynamicSelector> {
    let content = content.trim();
    // Remove parentheses and quotes
    let lang = content
        .strip_prefix('(')
        .and_then(|s| s.strip_suffix(')'))
        .unwrap_or(content)
        .trim();
    let lang = lang
        .strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .or_else(|| lang.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
        .unwrap_or(lang)
        .trim();
    if lang.is_empty() {
        return None;
    }
    // Use Prefix matching by default (e.g., "de" matches "de-DE", "de-AT")
    Some(DynamicSelector::Language(LanguageCondition::Prefix(
        AzString::from(lang.to_string()),
    )))
}
/// 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.
7611
fn new_from_str_inner<'a>(
7611
    css_string: &'a str,
7611
    tokenizer: &mut Tokenizer<'a>,
7611
) -> Result<(Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>), CssParseError<'a>> {
    use azul_simplecss::{Combinator, Token};
7611
    let mut css_blocks = Vec::new();
7611
    let mut warnings = Vec::new();
7611
    let mut block_nesting = 0_usize;
7611
    let mut last_path: Vec<CssPathSelector> = Vec::new();
7611
    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
7611
    let mut at_rule_stack: Vec<(Vec<DynamicSelector>, usize)> = Vec::new();
    // Pending @-rule that needs to be combined with AtStr tokens
7611
    let mut pending_at_rule: Option<&str> = None;
    // Collect multiple AtStr tokens (e.g., "screen", "(min-width: 800px)" for compound media queries)
7611
    let mut pending_at_str_parts: Vec<String> = Vec::new();
    // 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
    struct NestingLevel<'a> {
        paths: Vec<Vec<CssPathSelector>>,
        declarations: BTreeMap<&'a str, (&'a str, ErrorLocationRange)>,
        nesting_level: usize,
    }
7611
    let mut nesting_stack: Vec<NestingLevel<'a>> = Vec::new();
    // Current accumulated paths before BlockStart
7611
    let mut current_paths: Vec<Vec<CssPathSelector>> = Vec::new();
    // Current declarations at current level
7611
    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)
7611
    let max_iterations = css_string.len().saturating_mul(10).max(1000);
7611
    let mut iterations = 0_usize;
7611
    let mut last_position = 0_usize;
7611
    let mut stuck_count = 0_usize;
    loop {
        // Safety check 1: Maximum iterations
91444
        iterations += 1;
91444
        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;
91444
        }
        // Safety check 2: Detect if parser is stuck (position not advancing)
91444
        let current_position = tokenizer.pos();
91444
        if current_position == last_position {
7611
            stuck_count += 1;
7611
            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;
7611
            }
83833
        } else {
83833
            stuck_count = 0;
83833
            last_position = current_position;
83833
        }
91444
        let token = match tokenizer.parse_next() {
91402
            Ok(token) => token,
42
            Err(e) => {
42
                let error_location = get_error_location(tokenizer);
42
                warnings.push(CssParseWarnMsg {
42
                    warning: CssParseWarnMsgInner::ParseError(e.into()),
42
                    location: ErrorLocationRange { start: last_error_location, end: error_location },
42
                });
                // On error, break to avoid infinite loop - the tokenizer may be stuck
42
                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;
            }};
        }
        // Helper: get parent paths from nesting stack (if any)
14842
        fn get_parent_paths(nesting_stack: &[NestingLevel<'_>]) -> Vec<Vec<CssPathSelector>> {
14842
            if let Some(parent) = nesting_stack.last() {
42
                parent.paths.clone()
            } else {
14800
                Vec::new()
            }
14842
        }
        // 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()
            }
        }
782
        match token {
315
            Token::AtRule(rule_name) => {
315
                // Store the @-rule name to combine with the following AtStr tokens
315
                pending_at_rule = Some(rule_name);
315
                pending_at_str_parts.clear();
315
            }
343
            Token::AtStr(content) => {
                // Collect AtStr tokens until we see BlockStart
343
                if pending_at_rule.is_some() {
                    // Skip "and" keyword, it's just a separator
343
                    if !content.eq_ignore_ascii_case("and") {
329
                        pending_at_str_parts.push(content.to_string());
329
                    }
                }
            }
            Token::BlockStart => {
                // Process pending @-rule with all collected AtStr parts
15171
                if let Some(rule_name) = pending_at_rule.take() {
315
                    let combined_content = pending_at_str_parts.join(" and ");
315
                    pending_at_str_parts.clear();
315
                    let conditions = match rule_name.to_lowercase().as_str() {
315
                        "media" => parse_media_conditions(&combined_content),
203
                        "lang" => parse_lang_condition(&combined_content).into_iter().collect(),
203
                        "os" => crate::dynamic_selector::parse_os_at_rule_content(&combined_content).unwrap_or_default(),
                        "theme" => parse_theme_condition(&combined_content).into_iter().collect(),
                        "container" => parse_container_conditions(&combined_content),
                        _ => {
                            // Unknown @-rule, ignore
                            Vec::new()
                        }
                    };
315
                    if !conditions.is_empty() {
315
                        // Push conditions to stack, will be applied to nested rules
315
                        at_rule_stack.push((conditions, block_nesting + 1));
315
                    }
14856
                }
15171
                block_nesting += 1;
                // If we have a selector, push current state onto nesting stack
15171
                if !current_paths.is_empty() || !last_path.is_empty() {
                    // Finalize current_paths with last_path
14842
                    if !last_path.is_empty() {
14842
                        current_paths.push(last_path.clone());
14842
                        last_path.clear();
14842
                    }
                    // Get parent paths and combine with current paths
14842
                    let parent_paths = get_parent_paths(&nesting_stack);
14842
                    let combined_paths: Vec<Vec<CssPathSelector>> = if parent_paths.is_empty() {
14800
                        current_paths.clone()
                    } else {
                        // Combine each parent path with each current path
42
                        let mut result = Vec::new();
84
                        for parent in &parent_paths {
91
                            for child in &current_paths {
                                // Check if child starts with pseudo-selector
49
                                let is_pseudo_only = child.first().map(|s| matches!(s, CssPathSelector::PseudoSelector(_))).unwrap_or(false);
49
                                let mut combined = parent.clone();
49
                                if !is_pseudo_only && !child.is_empty() {
21
                                    combined.push(CssPathSelector::Children);
28
                                }
49
                                combined.extend(child.iter().cloned());
49
                                result.push(combined);
                            }
                        }
42
                        result
                    };
                    // Push to nesting stack
14842
                    nesting_stack.push(NestingLevel {
14842
                        paths: combined_paths,
14842
                        declarations: std::mem::take(&mut current_declarations),
14842
                        nesting_level: block_nesting,
14842
                    });
14842
                    current_paths.clear();
329
                }
            }
            Token::Comma => {
                // Comma separates selectors
73
                if !last_path.is_empty() {
73
                    current_paths.push(last_path.clone());
73
                    last_path.clear();
73
                }
            }
            Token::BlockEnd => {
15150
                if block_nesting == 0 {
                    warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
                        message: "Block end without matching block start"
                    });
15150
                }
                // Collect all conditions from the current @-rule stack
15150
                let current_conditions: Vec<DynamicSelector> = at_rule_stack
15150
                    .iter()
15150
                    .flat_map(|(conds, _)| conds.iter().cloned())
15150
                    .collect();
                // Pop @-rule conditions that are at this nesting level
15465
                while let Some((_, level)) = at_rule_stack.last() {
637
                    if *level >= block_nesting {
315
                        at_rule_stack.pop();
315
                    } else {
322
                        break;
                    }
                }
15150
                block_nesting = block_nesting.saturating_sub(1);
                // Pop from nesting stack if we have one
15150
                if let Some(level) = nesting_stack.pop() {
                    // Emit CSS blocks for all paths at this level
14821
                    if !level.paths.is_empty() && !current_declarations.is_empty() {
14744
                        css_blocks.extend(level.paths.iter().map(|path| UnparsedCssRuleBlock {
14817
                            path: CssPath {
14817
                                selectors: path.clone().into(),
14817
                            },
14817
                            declarations: current_declarations.clone(),
14817
                            conditions: current_conditions.clone(),
14817
                        }));
77
                    }
                    // Restore parent declarations
14821
                    current_declarations = level.declarations;
329
                }
15150
                last_path.clear();
15150
                current_paths.clear();
            }
2915
            Token::UniversalSelector => {
2915
                last_path.push(CssPathSelector::Global);
2915
            }
4793
            Token::TypeSelector(div_type) => {
4793
                match NodeTypeTag::from_str(div_type) {
4779
                    Ok(nt) => last_path.push(CssPathSelector::Type(nt)),
14
                    Err(e) => {
14
                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
14
                            selector: Some(div_type),
14
                            error: e.into(),
14
                        });
                    }
                }
            }
82
            Token::IdSelector(id) => {
82
                last_path.push(CssPathSelector::Id(id.to_string().into()));
82
            }
7879
            Token::ClassSelector(class) => {
7879
                last_path.push(CssPathSelector::Class(class.to_string().into()));
7879
            }
7
            Token::Combinator(Combinator::GreaterThan) => {
7
                last_path.push(CssPathSelector::DirectChildren);
7
            }
761
            Token::Combinator(Combinator::Space) => {
761
                last_path.push(CssPathSelector::Children);
761
            }
7
            Token::Combinator(Combinator::Plus) => {
7
                last_path.push(CssPathSelector::AdjacentSibling);
7
            }
7
            Token::Combinator(Combinator::Tilde) => {
7
                last_path.push(CssPathSelector::GeneralSibling);
7
            }
126
            Token::PseudoClass { selector, value } | Token::DoublePseudoClass { selector, value } => {
154
                match pseudo_selector_from_str(selector, value) {
119
                    Ok(ps) => last_path.push(CssPathSelector::PseudoSelector(ps)),
35
                    Err(e) => {
35
                        warn_and_continue!(CssParseWarnMsgInner::SkippedRule {
35
                            selector: Some(selector),
35
                            error: e.into(),
35
                        });
                    }
                }
            }
63
            Token::AttributeSelector(attr) => {
63
                match parse_attribute_selector(attr) {
63
                    Some(sel) => last_path.push(CssPathSelector::Attribute(sel)),
                    None => warn_and_continue!(CssParseWarnMsgInner::MalformedStructure {
                        message: "Malformed attribute selector, rule skipped",
                    }),
                }
            }
36113
            Token::Declaration(key, val) => {
36113
                current_declarations.insert(
36113
                    key,
36113
                    (val, ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) }),
36113
                );
36113
            }
            Token::EndOfStream => {
7569
                if block_nesting != 0 {
                    warnings.push(CssParseWarnMsg {
                        warning: CssParseWarnMsgInner::MalformedStructure {
                            message: "Unclosed blocks at end of file",
                        },
                        location: ErrorLocationRange { start: last_error_location, end: get_error_location(tokenizer) },
                    });
7569
                }
7569
                break;
            }
            _ => { /* Ignore unsupported tokens */ }
        }
83784
        last_error_location = get_error_location(tokenizer);
    }
    // Process the collected CSS blocks and convert warnings
7611
    let (stylesheet, mut block_warnings) = css_blocks_to_stylesheet(css_blocks, css_string);
7611
    warnings.append(&mut block_warnings);
7611
    Ok((stylesheet, warnings))
7611
}
7611
fn css_blocks_to_stylesheet<'a>(
7611
    css_blocks: Vec<UnparsedCssRuleBlock<'a>>,
7611
    css_string: &'a str,
7611
) -> (Vec<CssRuleBlock>, Vec<CssParseWarnMsg<'a>>) {
7611
    let css_key_map = crate::props::property::get_css_key_map();
7611
    let mut warnings = Vec::new();
7611
    let mut parsed_css_blocks = Vec::new();
22428
    for unparsed_css_block in css_blocks {
14817
        let mut declarations = Vec::<CssDeclaration>::new();
51034
        for (unparsed_css_key, (unparsed_css_value, location)) in &unparsed_css_block.declarations {
36217
            match parse_declaration_resilient(
36217
                unparsed_css_key,
36217
                unparsed_css_value,
36217
                *location,
36217
                &css_key_map,
36217
            ) {
36175
                Ok(decls) => declarations.extend(decls),
42
                Err(e) => {
42
                    warnings.push(CssParseWarnMsg {
42
                        warning: CssParseWarnMsgInner::SkippedDeclaration {
42
                            key: unparsed_css_key,
42
                            value: unparsed_css_value,
42
                            error: e,
42
                        },
42
                        location: *location,
42
                    });
42
                }
            }
        }
14817
        parsed_css_blocks.push(CssRuleBlock {
14817
            path: unparsed_css_block.path,
14817
            declarations: declarations.into(),
14817
            conditions: unparsed_css_block.conditions.into(),
14817
            priority: crate::css::rule_priority::AUTHOR,
14817
        });
    }
7611
    (parsed_css_blocks, warnings)
7611
}
36217
fn parse_declaration_resilient<'a>(
36217
    unparsed_css_key: &'a str,
36217
    unparsed_css_value: &'a str,
36217
    location: ErrorLocationRange,
36217
    css_key_map: &CssKeyMap,
36217
) -> Result<Vec<CssDeclaration>, CssParseErrorInner<'a>> {
36217
    let mut declarations = Vec::new();
36217
    if let Some(combined_key) = CombinedCssPropertyType::from_str(unparsed_css_key, css_key_map) {
14203
        if check_if_value_is_css_var(unparsed_css_value).is_some() {
            return Err(CssParseErrorInner::VarOnShorthandProperty {
                key: combined_key,
                value: unparsed_css_value,
            });
14203
        }
        // Attempt to parse combined properties, continue with what succeeds
14203
        match parse_combined_css_property(combined_key, unparsed_css_value) {
14189
            Ok(parsed_props) => {
14189
                declarations.extend(parsed_props.into_iter().map(CssDeclaration::Static));
14189
            }
14
            Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
        }
22014
    } else if let Some(normal_key) = CssPropertyType::from_str(unparsed_css_key, css_key_map) {
22000
        if let Some(css_var) = check_if_value_is_css_var(unparsed_css_value) {
            let (css_var_id, css_var_default) = css_var?;
            match parse_css_property(normal_key, css_var_default) {
                Ok(parsed_default) => {
                    declarations.push(CssDeclaration::Dynamic(DynamicCssProperty {
                        dynamic_id: css_var_id.to_string().into(),
                        default_value: parsed_default,
                    }));
                }
                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
            }
        } else {
22000
            match parse_css_property(normal_key, unparsed_css_value) {
21986
                Ok(parsed_value) => {
21986
                    declarations.push(CssDeclaration::Static(parsed_value));
21986
                }
14
                Err(e) => return Err(CssParseErrorInner::DynamicCssParseError(e.into())),
            }
        }
    } else {
14
        return Err(CssParseErrorInner::UnknownPropertyKey(
14
            unparsed_css_key,
14
            unparsed_css_value,
14
        ));
    }
36175
    Ok(declarations)
36217
}
/// 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.
pub fn parse_css_declaration<'a>(
    unparsed_css_key: &'a str,
    unparsed_css_value: &'a str,
    location: ErrorLocationRange,
    css_key_map: &CssKeyMap,
    warnings: &mut Vec<CssParseWarnMsg<'a>>,
    declarations: &mut Vec<CssDeclaration>,
) -> Result<(), CssParseErrorInner<'a>> {
    match parse_declaration_resilient(unparsed_css_key, unparsed_css_value, location, css_key_map) {
        Ok(mut decls) => {
            declarations.append(&mut decls);
            Ok(())
        }
        Err(e) => {
            if let CssParseErrorInner::UnknownPropertyKey(key, val) = &e {
                warnings.push(CssParseWarnMsg {
                    warning: CssParseWarnMsgInner::UnsupportedKeyValuePair { key, value: val },
                    location,
                });
                Ok(()) // Continue processing despite unknown property
            } else {
                Err(e) // Propagate other errors
            }
        }
    }
}
36203
fn check_if_value_is_css_var<'a>(
36203
    unparsed_css_value: &'a str,
36203
) -> Option<Result<(&'a str, &'a str), CssParseErrorInner<'a>>> {
    const DEFAULT_VARIABLE_DEFAULT: &str = "none";
36203
    let (_, brace_contents) = parse_parentheses(unparsed_css_value, &["var"]).ok()?;
    // value is a CSS variable, i.e. var(--main-bg-color)
    Some(match parse_css_variable_brace_contents(brace_contents) {
        Some((variable_id, default_value)) => Ok((
            variable_id,
            default_value.unwrap_or(DEFAULT_VARIABLE_DEFAULT),
        )),
        None => Err(DynamicCssParseError::InvalidBraceContents(brace_contents).into()),
    })
36203
}
/// 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)
/// ```
fn parse_css_variable_brace_contents(input: &str) -> Option<(&str, Option<&str>)> {
    let input = input.trim();
    let mut split_comma_iter = input.splitn(2, ",");
    let var_name = split_comma_iter.next()?;
    let var_name = var_name.trim();
    if !var_name.starts_with("--") {
        return None; // no proper CSS variable name
    }
    Some((&var_name[2..], split_comma_iter.next()))
}