1
//! CSS string parsing utilities.
2
//!
3
//! Parenthesized expressions, quote stripping,
4
//! comma/whitespace-aware splitting that respects nesting depth, and CSS
5
//! image/url path parsing.
6

            
7
use crate::corety::AzString;
8

            
9
/// Splits a string by commas, but respects parentheses/braces
10
///
11
/// E.g. `url(something,else), url(another,thing)` becomes `["url(something,else)",
12
/// "url(another,thing)"]` whereas a normal split by comma would yield `["url(something", "else)",
13
/// "url(another", "thing)"]`
14
#[must_use]
15
53109
pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
16
53109
    split_string_by_char(input, ',')
17
53109
}
18

            
19
/// Splits a string by whitespace, but respects parentheses/braces
20
///
21
/// E.g. `translateX(10px) rotate(90deg)` becomes `["translateX(10px)", "rotate(90deg)"]`
22
#[must_use]
23
921
pub fn split_string_respect_whitespace(input: &str) -> Vec<&str> {
24
921
    let mut items = Vec::<&str>::new();
25
921
    let mut current_start = 0;
26
921
    let mut depth = 0;
27
921
    let input_bytes = input.as_bytes();
28

            
29
2374943
    for (idx, &ch) in input_bytes.iter().enumerate() {
30
1020138
        match ch {
31
35940
            b'(' => depth += 1,
32
45940
            b')' => depth -= 1,
33
1020138
            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
34
1020074
                if current_start < idx {
35
20048
                    items.push(&input[current_start..idx]);
36
1000040
                }
37
1020074
                current_start = idx + 1;
38
            }
39
1272989
            _ => {}
40
        }
41
    }
42

            
43
    // Add the last segment
44
921
    if current_start < input.len() {
45
909
        items.push(&input[current_start..]);
46
909
    }
47

            
48
921
    items
49
921
}
50

            
51
53115
fn split_string_by_char(input: &str, target_char: char) -> Vec<&str> {
52
53115
    let mut comma_separated_items = Vec::<&str>::new();
53
53115
    let mut current_input = input;
54

            
55
    'outer: loop {
56
361607
        let Some((skip_next_braces_result, character_was_found)) =
57
361749
            skip_next_braces(current_input, target_char)
58
        else {
59
142
            break 'outer;
60
        };
61
361607
        if character_was_found {
62
308634
            comma_separated_items.push(&current_input[..skip_next_braces_result]);
63
308634
            current_input = &current_input[(skip_next_braces_result + 1)..];
64
308634
        } else {
65
52973
            comma_separated_items.push(current_input);
66
52973
            break 'outer;
67
        }
68
    }
69

            
70
53115
    comma_separated_items
71
53115
}
72

            
73
/// Given a string, returns how many characters need to be skipped
74
361775
fn skip_next_braces(input: &str, target_char: char) -> Option<(usize, bool)> {
75
361775
    let mut depth = 0;
76
361775
    let mut last_character: Option<usize> = None;
77
361775
    let mut character_was_found = false;
78

            
79
361775
    if input.is_empty() {
80
145
        return None;
81
361630
    }
82

            
83
5746631
    for (idx, ch) in input.char_indices() {
84
5746631
        last_character = Some(idx);
85
5746631
        match ch {
86
210176
            '(' => {
87
210176
                depth += 1;
88
210176
            }
89
210069
            ')' => {
90
210069
                depth -= 1;
91
210069
            }
92
5326386
            c => {
93
5326386
                if c == target_char && depth == 0 {
94
308642
                    character_was_found = true;
95
308642
                    break;
96
5017744
                }
97
            }
98
        }
99
    }
100

            
101
361630
    last_character.map(|lc| (lc, character_was_found))
102
361775
}
103

            
104
#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
105
pub enum ParenthesisParseError<'a> {
106
    UnclosedBraces,
107
    NoOpeningBraceFound,
108
    NoClosingBraceFound,
109
    StopWordNotFound(&'a str),
110
    EmptyInput,
111
}
112

            
113
impl_display! { ParenthesisParseError<'a>, {
114
    UnclosedBraces => format!("Unclosed parenthesis"),
115
    NoOpeningBraceFound => format!("Expected value in parenthesis (missing \"(\")"),
116
    NoClosingBraceFound => format!("Missing closing parenthesis (missing \")\")"),
117
    StopWordNotFound(e) => format!("Stopword not found, found: \"{}\"", e),
118
    EmptyInput => format!("Empty parenthesis"),
119
}}
120
#[allow(variant_size_differences)]
121
// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size
122
// disparity accepted
123
/// Owned version of `ParenthesisParseError`.
124
#[derive(Debug, Clone, PartialEq, Eq)]
125
#[repr(C, u8)]
126
pub enum ParenthesisParseErrorOwned {
127
    UnclosedBraces,
128
    NoOpeningBraceFound,
129
    NoClosingBraceFound,
130
    StopWordNotFound(AzString),
131
    EmptyInput,
132
}
133

            
134
impl ParenthesisParseError<'_> {
135
    #[must_use]
136
48
    pub fn to_contained(&self) -> ParenthesisParseErrorOwned {
137
48
        match self {
138
7
            ParenthesisParseError::UnclosedBraces => ParenthesisParseErrorOwned::UnclosedBraces,
139
            ParenthesisParseError::NoOpeningBraceFound => {
140
7
                ParenthesisParseErrorOwned::NoOpeningBraceFound
141
            }
142
            ParenthesisParseError::NoClosingBraceFound => {
143
6
                ParenthesisParseErrorOwned::NoClosingBraceFound
144
            }
145
20
            ParenthesisParseError::StopWordNotFound(s) => {
146
20
                ParenthesisParseErrorOwned::StopWordNotFound((*s).to_string().into())
147
            }
148
8
            ParenthesisParseError::EmptyInput => ParenthesisParseErrorOwned::EmptyInput,
149
        }
150
48
    }
151
}
152

            
153
impl ParenthesisParseErrorOwned {
154
    #[must_use]
155
39
    pub fn to_shared(&self) -> ParenthesisParseError<'_> {
156
39
        match self {
157
4
            Self::UnclosedBraces => ParenthesisParseError::UnclosedBraces,
158
5
            Self::NoOpeningBraceFound => ParenthesisParseError::NoOpeningBraceFound,
159
4
            Self::NoClosingBraceFound => ParenthesisParseError::NoClosingBraceFound,
160
19
            Self::StopWordNotFound(s) => ParenthesisParseError::StopWordNotFound(s.as_str()),
161
7
            Self::EmptyInput => ParenthesisParseError::EmptyInput,
162
        }
163
39
    }
164
}
165

            
166
/// Checks whether a given input is enclosed in parentheses, prefixed
167
/// by a certain number of stopwords.
168
///
169
/// On success, returns what the stopword was + the string inside the braces
170
/// on failure returns None.
171
///
172
/// ```rust
173
/// # use azul_css::props::basic::parse::{parse_parentheses, ParenthesisParseError::*};
174
/// // Search for the nearest "abc()" brace
175
/// assert_eq!(
176
///     parse_parentheses("abc(def(g))", &["abc"]),
177
///     Ok(("abc", "def(g)"))
178
/// );
179
/// assert_eq!(
180
///     parse_parentheses("abc(def(g))", &["def"]),
181
///     Err(StopWordNotFound("abc"))
182
/// );
183
/// assert_eq!(
184
///     parse_parentheses("def(ghi(j))", &["def"]),
185
///     Ok(("def", "ghi(j)"))
186
/// );
187
/// assert_eq!(
188
///     parse_parentheses("abc(def(g))", &["abc", "def"]),
189
///     Ok(("abc", "def(g)"))
190
/// );
191
/// ```
192
/// # Errors
193
///
194
/// Returns an error if `input` is not a valid CSS `parentheses` value.
195
1573276
pub fn parse_parentheses<'a>(
196
1573276
    input: &'a str,
197
1573276
    stopwords: &[&'static str],
198
1573276
) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
199
    use self::ParenthesisParseError::{
200
        EmptyInput, NoClosingBraceFound, NoOpeningBraceFound, StopWordNotFound,
201
    };
202

            
203
1573276
    let input = input.trim();
204
1573276
    if input.is_empty() {
205
251
        return Err(EmptyInput);
206
1573025
    }
207

            
208
1573025
    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
209
82590
    let found_stopword = &input[..first_open_brace];
210

            
211
    // CSS does not allow for space between the ( and the stopword, so no .trim() here
212
82590
    let mut validated_stopword = None;
213
249511
    for stopword in stopwords {
214
218716
        if found_stopword == *stopword {
215
51795
            validated_stopword = Some(stopword);
216
51795
            break;
217
166921
        }
218
    }
219

            
220
82590
    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
221
51795
    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
222

            
223
46220
    Ok((
224
46220
        validated_stopword,
225
46220
        &input[(first_open_brace + 1)..last_closing_brace],
226
46220
    ))
227
1573276
}
228

            
229
/// String has unbalanced `'` or `"` quotation marks
230
#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
231
pub struct UnclosedQuotesError<'a>(pub &'a str);
232

            
233
impl<'a> From<UnclosedQuotesError<'a>> for CssImageParseError<'a> {
234
1
    fn from(err: UnclosedQuotesError<'a>) -> Self {
235
1
        CssImageParseError::UnclosedQuotes(err.0)
236
1
    }
237
}
238

            
239
/// A string that has been stripped of the beginning and ending quote
240
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
241
pub struct QuoteStripped<'a>(pub &'a str);
242

            
243
/// Strip quotes from an input, given that both quotes use either `"` or `'`, but not both.
244
///
245
/// # Example
246
///
247
/// ```rust
248
/// # extern crate azul_css;
249
/// # use azul_css::props::basic::parse::{strip_quotes, QuoteStripped, UnclosedQuotesError};
250
/// assert_eq!(
251
///     strip_quotes("\"Helvetica\""),
252
///     Ok(QuoteStripped("Helvetica"))
253
/// );
254
/// assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
255
/// assert_eq!(
256
///     strip_quotes("\"Arial'"),
257
///     Err(UnclosedQuotesError("\"Arial'"))
258
/// );
259
/// ```
260
/// # Errors
261
///
262
/// Returns an error if `input` has an opening quote with no matching closing quote.
263
13961
pub fn strip_quotes(input: &str) -> Result<QuoteStripped<'_>, UnclosedQuotesError<'_>> {
264
13961
    let mut double_quote_iter = input.splitn(2, '"');
265
13961
    double_quote_iter.next();
266
13961
    let mut single_quote_iter = input.splitn(2, '\'');
267
13961
    single_quote_iter.next();
268

            
269
13961
    let first_double_quote = double_quote_iter.next();
270
13961
    let first_single_quote = single_quote_iter.next();
271
13961
    if first_double_quote.is_some() && first_single_quote.is_some() {
272
8
        return Err(UnclosedQuotesError(input));
273
13953
    }
274
13953
    if let Some(quote_contents) = first_double_quote {
275
874
        if !quote_contents.ends_with('"') {
276
9
            return Err(UnclosedQuotesError(quote_contents));
277
865
        }
278
865
        Ok(QuoteStripped(quote_contents.trim_end_matches('"')))
279
13079
    } else if let Some(quote_contents) = first_single_quote {
280
1173
        if !quote_contents.ends_with('\'') {
281
8
            return Err(UnclosedQuotesError(input));
282
1165
        }
283
1165
        Ok(QuoteStripped(quote_contents.trim_end_matches('\'')))
284
    } else {
285
11906
        Err(UnclosedQuotesError(input))
286
    }
287
13961
}
288

            
289
#[derive(Copy, Clone, PartialEq, Eq)]
290
pub enum CssImageParseError<'a> {
291
    UnclosedQuotes(&'a str),
292
}
293

            
294
impl_debug_as_display!(CssImageParseError<'a>);
295
impl_display! {CssImageParseError<'a>, {
296
    UnclosedQuotes(e) => format!("Unclosed quotes: \"{}\"", e),
297
}}
298

            
299
/// Owned version of `CssImageParseError`.
300
#[derive(Debug, Clone, PartialEq, Eq)]
301
#[repr(C, u8)]
302
pub enum CssImageParseErrorOwned {
303
    UnclosedQuotes(AzString),
304
}
305

            
306
impl CssImageParseError<'_> {
307
    /// Converts to the owned variant.
308
    #[must_use]
309
11
    pub fn to_contained(&self) -> CssImageParseErrorOwned {
310
11
        match self {
311
11
            CssImageParseError::UnclosedQuotes(s) => {
312
11
                CssImageParseErrorOwned::UnclosedQuotes((*s).to_string().into())
313
            }
314
        }
315
11
    }
316
}
317

            
318
impl CssImageParseErrorOwned {
319
    /// Converts to the borrowed variant.
320
    #[must_use]
321
12
    pub fn to_shared(&self) -> CssImageParseError<'_> {
322
12
        match self {
323
12
            Self::UnclosedQuotes(s) => CssImageParseError::UnclosedQuotes(s.as_str()),
324
        }
325
12
    }
326
}
327

            
328
/// A string slice that has been stripped of its quotes.
329
/// In CSS, quotes are optional in `url()` so we accept both quoted and unquoted strings.
330
/// # Errors
331
///
332
/// Returns an error if `input` is not a valid CSS `image` value.
333
78
pub fn parse_image(input: &str) -> Result<AzString, CssImageParseError<'_>> {
334
78
    Ok(strip_quotes(input).map_or_else(|_| input.trim().into(), |stripped| stripped.0.into()))
335
78
}
336

            
337
#[cfg(all(test, feature = "parser"))]
338
mod tests {
339
    use super::*;
340

            
341
    #[test]
342
1
    fn test_strip_quotes() {
343
1
        assert_eq!(strip_quotes("'hello'").unwrap(), QuoteStripped("hello"));
344
1
        assert_eq!(strip_quotes("\"world\"").unwrap(), QuoteStripped("world"));
345
1
        assert_eq!(
346
1
            strip_quotes("\"  spaced  \"").unwrap(),
347
            QuoteStripped("  spaced  ")
348
        );
349
1
        assert!(strip_quotes("'unclosed").is_err());
350
1
        assert!(strip_quotes("\"mismatched'").is_err());
351
1
        assert!(strip_quotes("no-quotes").is_err());
352
1
    }
353

            
354
    #[test]
355
1
    fn test_parse_parentheses() {
356
1
        assert_eq!(
357
1
            parse_parentheses("url(image.png)", &["url"]),
358
            Ok(("url", "image.png"))
359
        );
360
1
        assert_eq!(
361
1
            parse_parentheses("linear-gradient(red, blue)", &["linear-gradient"]),
362
            Ok(("linear-gradient", "red, blue"))
363
        );
364
1
        assert_eq!(
365
1
            parse_parentheses("var(--my-var, 10px)", &["var"]),
366
            Ok(("var", "--my-var, 10px"))
367
        );
368
1
        assert_eq!(
369
1
            parse_parentheses("  rgb( 255, 0, 0 )  ", &["rgb", "rgba"]),
370
            Ok(("rgb", " 255, 0, 0 "))
371
        );
372
1
    }
373

            
374
    #[test]
375
1
    fn test_parse_parentheses_errors() {
376
        // Stopword not found
377
1
        assert!(parse_parentheses("rgba(255,0,0,1)", &["rgb"]).is_err());
378
        // No opening brace
379
1
        assert!(parse_parentheses("url'image.png'", &["url"]).is_err());
380
        // No closing brace
381
1
        assert!(parse_parentheses("url(image.png", &["url"]).is_err());
382
1
    }
383

            
384
    #[test]
385
1
    fn test_split_string_respect_comma() {
386
        // Simple case
387
1
        let simple = "one, two, three";
388
1
        assert_eq!(
389
1
            split_string_respect_comma(simple),
390
1
            vec!["one", " two", " three"]
391
        );
392

            
393
        // With parentheses
394
1
        let with_parens = "rgba(255, 0, 0, 1), #ff00ff";
395
1
        assert_eq!(
396
1
            split_string_respect_comma(with_parens),
397
1
            vec!["rgba(255, 0, 0, 1)", " #ff00ff"]
398
        );
399

            
400
        // Multiple parentheses
401
1
        let multi_parens =
402
1
            "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)), url(image.png)";
403
1
        assert_eq!(
404
1
            split_string_respect_comma(multi_parens),
405
1
            vec![
406
                "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))",
407
1
                " url(image.png)"
408
            ]
409
        );
410

            
411
        // No commas
412
1
        let no_commas = "rgb(0,0,0)";
413
1
        assert_eq!(split_string_respect_comma(no_commas), vec!["rgb(0,0,0)"]);
414
1
    }
415
}
416

            
417
#[cfg(test)]
418
mod autotest_generated {
419
    use super::*;
420

            
421
    // ---------------------------------------------------------------------
422
    // skip_next_braces (private, parser)
423
    // ---------------------------------------------------------------------
424

            
425
    #[test]
426
    fn skip_next_braces_empty_input_returns_none() {
427
        assert_eq!(skip_next_braces("", ','), None);
428
        assert_eq!(skip_next_braces("", '('), None);
429
        assert_eq!(skip_next_braces("", '\0'), None);
430
    }
431

            
432
    #[test]
433
    fn skip_next_braces_not_found_yields_last_char_start_not_len() {
434
        // NOTE: the returned index is the byte offset of the *last char*, not the
435
        // string length. Callers must not treat it as an exclusive end bound.
436
        assert_eq!(skip_next_braces("abc", ','), Some((2, false)));
437
        assert_eq!(skip_next_braces("a", ','), Some((0, false)));
438
        // 4-byte emoji: index is the char start (0), never a mid-char offset.
439
        assert_eq!(skip_next_braces("\u{1F600}", ','), Some((0, false)));
440
    }
441

            
442
    #[test]
443
    fn skip_next_braces_finds_target_only_at_depth_zero() {
444
        assert_eq!(skip_next_braces("a,b", ','), Some((1, true)));
445
        // Comma nested inside parens is invisible; falls through to "not found".
446
        assert_eq!(skip_next_braces("(a,b)", ','), Some((4, false)));
447
        // First depth-0 comma, after the group closes.
448
        assert_eq!(skip_next_braces("(a,b),c", ','), Some((5, true)));
449
    }
450

            
451
    #[test]
452
    fn skip_next_braces_unbalanced_closing_paren_drives_depth_negative() {
453
        // A stray ')' makes depth == -1, so no later comma is ever "at depth 0".
454
        // Deterministic + no panic, but the separator is silently swallowed.
455
        assert_eq!(skip_next_braces(")a,b", ','), Some((3, false)));
456
        assert_eq!(skip_next_braces("))))", ','), Some((3, false)));
457
    }
458

            
459
    #[test]
460
    fn skip_next_braces_paren_as_target_char_can_never_match() {
461
        // The '(' / ')' match arms shadow the target-char arm, so asking for a
462
        // parenthesis as the separator always reports "not found".
463
        assert_eq!(skip_next_braces("a(b", '('), Some((2, false)));
464
        assert_eq!(skip_next_braces("a)b", ')'), Some((2, false)));
465
    }
466

            
467
    #[test]
468
    fn skip_next_braces_whitespace_only() {
469
        assert_eq!(skip_next_braces("   ", ','), Some((2, false)));
470
        assert_eq!(skip_next_braces("\t\n", ','), Some((1, false)));
471
        assert_eq!(skip_next_braces("   ", ' '), Some((0, true)));
472
    }
473

            
474
    #[test]
475
    fn skip_next_braces_boundary_number_strings() {
476
        assert_eq!(skip_next_braces("0", ','), Some((0, false)));
477
        assert_eq!(skip_next_braces("-0", ','), Some((1, false)));
478
        assert_eq!(
479
            skip_next_braces("9223372036854775807", ','),
480
            Some((18, false))
481
        );
482
        assert_eq!(skip_next_braces("NaN,inf", ','), Some((3, true)));
483
        assert_eq!(skip_next_braces("1e309,-1e309", ','), Some((5, true)));
484
    }
485

            
486
    #[test]
487
    fn skip_next_braces_unicode_indices_stay_on_char_boundaries() {
488
        // "e" + combining acute (2 bytes) => comma sits at byte 3.
489
        assert_eq!(skip_next_braces("e\u{0301},x", ','), Some((3, true)));
490
        // 4-byte emoji then comma at byte 4.
491
        assert_eq!(skip_next_braces("\u{1F600},x", ','), Some((4, true)));
492
        let s = "\u{1F600}\u{0301}\u{4E2D}";
493
        let (idx, found) = skip_next_braces(s, ',').expect("non-empty input");
494
        assert!(!found);
495
        assert!(s.is_char_boundary(idx));
496
    }
497

            
498
    #[test]
499
    fn skip_next_braces_extremely_long_input_terminates() {
500
        let mut input = "a".repeat(1_000_000);
501
        input.push(',');
502
        assert_eq!(skip_next_braces(&input, ','), Some((1_000_000, true)));
503
    }
504

            
505
    #[test]
506
    fn skip_next_braces_deeply_nested_does_not_stack_overflow() {
507
        let input = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
508
        // Iterative, not recursive: 20k parens, no target found.
509
        assert_eq!(skip_next_braces(&input, ','), Some((19_999, false)));
510
    }
511

            
512
    // ---------------------------------------------------------------------
513
    // split_string_by_char (private, other)
514
    // ---------------------------------------------------------------------
515

            
516
    #[test]
517
    fn split_string_by_char_empty_input_yields_empty_vec() {
518
        // NOTE: differs from str::split, which yields [""] for an empty input.
519
        assert!(split_string_by_char("", ',').is_empty());
520
        assert!(split_string_by_char("", ';').is_empty());
521
    }
522

            
523
    #[test]
524
    fn split_string_by_char_respects_nesting_for_any_ascii_separator() {
525
        assert_eq!(
526
            split_string_by_char("a;b(c;d);e", ';'),
527
            vec!["a", "b(c;d)", "e"]
528
        );
529
        assert_eq!(
530
            split_string_by_char("a b(c d) e", ' '),
531
            vec!["a", "b(c d)", "e"]
532
        );
533
    }
534

            
535
    #[test]
536
    fn split_string_by_char_paren_separator_never_splits() {
537
        assert_eq!(split_string_by_char("a(b)c", '('), vec!["a(b)c"]);
538
        assert_eq!(split_string_by_char("a(b)c", ')'), vec!["a(b)c"]);
539
    }
540

            
541
    // ---------------------------------------------------------------------
542
    // split_string_respect_comma (other)
543
    // ---------------------------------------------------------------------
544

            
545
    #[test]
546
    fn split_comma_empty_and_separator_only_inputs() {
547
        assert!(split_string_respect_comma("").is_empty());
548
        assert_eq!(split_string_respect_comma(","), vec![""]);
549
        assert_eq!(split_string_respect_comma(",,"), vec!["", ""]);
550
        assert_eq!(split_string_respect_comma("a,,b"), vec!["a", "", "b"]);
551
    }
552

            
553
    #[test]
554
    fn split_comma_trailing_separator_drops_the_empty_tail() {
555
        // Asymmetric: a leading comma keeps its empty segment, a trailing one
556
        // does not. Pinned so a future refactor has to acknowledge the change.
557
        assert_eq!(split_string_respect_comma("a,"), vec!["a"]);
558
        assert_eq!(split_string_respect_comma(",a"), vec!["", "a"]);
559
    }
560

            
561
    #[test]
562
    fn split_comma_unbalanced_closing_paren_swallows_separators() {
563
        // Stray ')' => depth goes negative => nothing splits. No panic.
564
        assert_eq!(split_string_respect_comma("a),b"), vec!["a),b"]);
565
        assert_eq!(split_string_respect_comma("a(b,c"), vec!["a(b,c"]);
566
    }
567

            
568
    #[test]
569
    fn split_comma_respects_balanced_nesting() {
570
        assert_eq!(
571
            split_string_respect_comma("rgba(1,2,3),url(a,b)"),
572
            vec!["rgba(1,2,3)", "url(a,b)"]
573
        );
574
        assert_eq!(
575
            split_string_respect_comma("f(g(h(1,2),3),4),5"),
576
            vec!["f(g(h(1,2),3),4)", "5"]
577
        );
578
    }
579

            
580
    #[test]
581
    fn split_comma_unicode_segments_are_valid_utf8() {
582
        assert_eq!(
583
            split_string_respect_comma("\u{1F600},h\u{E9}llo,\u{FC}"),
584
            vec!["\u{1F600}", "h\u{E9}llo", "\u{FC}"]
585
        );
586
        // Combining marks must not be sliced apart.
587
        assert_eq!(
588
            split_string_respect_comma("e\u{0301},a\u{0308}"),
589
            vec!["e\u{0301}", "a\u{0308}"]
590
        );
591
    }
592

            
593
    #[test]
594
    fn split_comma_garbage_input_never_panics() {
595
        for garbage in [
596
            "\0",
597
            "\u{FFFD}",
598
            ";;;",
599
            "((((",
600
            "))))",
601
            "()",
602
            ",()",
603
            "(),",
604
            "\\\"'`",
605
            "\u{200B},\u{200B}",
606
            "--,--",
607
            "\t,\n,\r",
608
        ] {
609
            let parts = split_string_respect_comma(garbage);
610
            // Every returned slice must be a real substring of the input.
611
            for p in &parts {
612
                assert!(garbage.contains(p));
613
            }
614
        }
615
    }
616

            
617
    #[test]
618
    fn split_comma_extremely_long_inputs_do_not_hang() {
619
        let no_comma = "a".repeat(1_000_000);
620
        assert_eq!(
621
            split_string_respect_comma(&no_comma),
622
            vec![no_comma.as_str()]
623
        );
624

            
625
        let all_commas = ",".repeat(100_000);
626
        let parts = split_string_respect_comma(&all_commas);
627
        assert_eq!(parts.len(), 100_000);
628
        assert!(parts.iter().all(|p| p.is_empty()));
629
    }
630

            
631
    #[test]
632
    fn split_comma_deeply_nested_does_not_stack_overflow() {
633
        let nested = format!("{}1,2{}", "(".repeat(10_000), ")".repeat(10_000));
634
        // Every comma is nested => a single segment.
635
        assert_eq!(split_string_respect_comma(&nested), vec![nested.as_str()]);
636
    }
637

            
638
    #[test]
639
    fn split_comma_round_trips_via_join_when_no_trailing_separator() {
640
        for input in [
641
            "a,b,c",
642
            "one, two, three",
643
            "rgba(1,2,3),x",
644
            "a,,b",
645
            ",a",
646
            "rgb(0,0,0)",
647
        ] {
648
            assert_eq!(split_string_respect_comma(input).join(","), input);
649
        }
650
    }
651

            
652
    // ---------------------------------------------------------------------
653
    // split_string_respect_whitespace (other)
654
    // ---------------------------------------------------------------------
655

            
656
    #[test]
657
    fn split_whitespace_empty_and_blank_inputs_yield_nothing() {
658
        assert!(split_string_respect_whitespace("").is_empty());
659
        assert!(split_string_respect_whitespace("   ").is_empty());
660
        assert!(split_string_respect_whitespace("\t\n\r").is_empty());
661
    }
662

            
663
    #[test]
664
    fn split_whitespace_valid_minimal_and_run_collapsing() {
665
        assert_eq!(
666
            split_string_respect_whitespace("translateX(10px) rotate(90deg)"),
667
            vec!["translateX(10px)", "rotate(90deg)"]
668
        );
669
        assert_eq!(
670
            split_string_respect_whitespace("  a\t\tb\n"),
671
            vec!["a", "b"]
672
        );
673
    }
674

            
675
    #[test]
676
    fn split_whitespace_respects_balanced_nesting() {
677
        assert_eq!(
678
            split_string_respect_whitespace("translate( 10px , 20px ) scale(2)"),
679
            vec!["translate( 10px , 20px )", "scale(2)"]
680
        );
681
    }
682

            
683
    #[test]
684
    fn split_whitespace_unbalanced_closing_paren_disables_splitting() {
685
        assert_eq!(split_string_respect_whitespace("a) b"), vec!["a) b"]);
686
        assert_eq!(split_string_respect_whitespace("a( b"), vec!["a( b"]);
687
    }
688

            
689
    #[test]
690
    fn split_whitespace_unicode_is_split_on_ascii_bytes_only() {
691
        // Scanning is byte-wise; UTF-8 continuation bytes are >= 0x80 so they can
692
        // never be mistaken for a space/paren => slices stay on char boundaries.
693
        assert_eq!(
694
            split_string_respect_whitespace("h\u{E9}llo w\u{F6}rld \u{1F600}"),
695
            vec!["h\u{E9}llo", "w\u{F6}rld", "\u{1F600}"]
696
        );
697
        // U+00A0 NBSP is *not* an ASCII space => not a separator.
698
        assert_eq!(
699
            split_string_respect_whitespace("a\u{A0}b"),
700
            vec!["a\u{A0}b"]
701
        );
702
    }
703

            
704
    #[test]
705
    fn split_whitespace_garbage_input_never_panics() {
706
        for garbage in ["\0", "((((", "))))", ")(", "\u{FFFD} \u{FFFD}", "  ) (  "] {
707
            for p in &split_string_respect_whitespace(garbage) {
708
                assert!(garbage.contains(p));
709
            }
710
        }
711
    }
712

            
713
    #[test]
714
    fn split_whitespace_extremely_long_inputs_do_not_hang() {
715
        let blanks = " ".repeat(1_000_000);
716
        assert!(split_string_respect_whitespace(&blanks).is_empty());
717

            
718
        let word = "a".repeat(1_000_000);
719
        assert_eq!(split_string_respect_whitespace(&word), vec![word.as_str()]);
720
    }
721

            
722
    #[test]
723
    fn split_whitespace_deeply_nested_does_not_stack_overflow() {
724
        let nested = format!("{}a{}", "(".repeat(10_000), ")".repeat(10_000));
725
        let input = format!("{nested} z");
726
        assert_eq!(
727
            split_string_respect_whitespace(&input),
728
            vec![nested.as_str(), "z"]
729
        );
730
    }
731

            
732
    // ---------------------------------------------------------------------
733
    // parse_parentheses (parser)
734
    // ---------------------------------------------------------------------
735

            
736
    #[test]
737
    fn parse_parentheses_empty_and_whitespace_only_input() {
738
        assert_eq!(
739
            parse_parentheses("", &["url"]),
740
            Err(ParenthesisParseError::EmptyInput)
741
        );
742
        assert_eq!(
743
            parse_parentheses("   ", &["url"]),
744
            Err(ParenthesisParseError::EmptyInput)
745
        );
746
        assert_eq!(
747
            parse_parentheses("\t\n", &["url"]),
748
            Err(ParenthesisParseError::EmptyInput)
749
        );
750
        // Empty stopword list can never validate.
751
        assert_eq!(
752
            parse_parentheses("url(a)", &[]),
753
            Err(ParenthesisParseError::StopWordNotFound("url"))
754
        );
755
    }
756

            
757
    #[test]
758
    fn parse_parentheses_valid_minimal_positive_control() {
759
        assert_eq!(parse_parentheses("a(b)", &["a"]), Ok(("a", "b")));
760
        assert_eq!(parse_parentheses("abc()", &["abc"]), Ok(("abc", "")));
761
        assert_eq!(
762
            parse_parentheses("abc(def(g))", &["abc", "def"]),
763
            Ok(("abc", "def(g)"))
764
        );
765
    }
766

            
767
    #[test]
768
    fn parse_parentheses_missing_braces_and_stopword() {
769
        assert_eq!(
770
            parse_parentheses("abc", &["abc"]),
771
            Err(ParenthesisParseError::NoOpeningBraceFound)
772
        );
773
        assert_eq!(
774
            parse_parentheses("url(image.png", &["url"]),
775
            Err(ParenthesisParseError::NoClosingBraceFound)
776
        );
777
        assert_eq!(
778
            parse_parentheses("rgba(1,2,3,4)", &["rgb"]),
779
            Err(ParenthesisParseError::StopWordNotFound("rgba"))
780
        );
781
    }
782

            
783
    #[test]
784
    fn parse_parentheses_stopword_must_directly_abut_the_brace() {
785
        // CSS forbids whitespace between the function name and '('; the inner
786
        // whitespace survives the outer trim, so this must not validate.
787
        assert_eq!(
788
            parse_parentheses("url (x)", &["url"]),
789
            Err(ParenthesisParseError::StopWordNotFound("url "))
790
        );
791
        assert_eq!(
792
            parse_parentheses("URL(x)", &["url"]),
793
            Err(ParenthesisParseError::StopWordNotFound("URL"))
794
        );
795
    }
796

            
797
    #[test]
798
    fn parse_parentheses_uses_last_closing_brace_and_drops_trailing_junk() {
799
        // rfind(')') => everything after the *last* ')' is silently discarded.
800
        assert_eq!(parse_parentheses("url(a)b)", &["url"]), Ok(("url", "a)b")));
801
        assert_eq!(
802
            parse_parentheses("url(a);garbage", &["url"]),
803
            Ok(("url", "a"))
804
        );
805
        // Outer whitespace is trimmed, inner whitespace is preserved verbatim.
806
        assert_eq!(
807
            parse_parentheses("  rgb( 1 )  ", &["rgb", "rgba"]),
808
            Ok(("rgb", " 1 "))
809
        );
810
    }
811

            
812
    #[test]
813
    fn parse_parentheses_boundary_number_strings_pass_through_verbatim() {
814
        for n in [
815
            "0",
816
            "-0",
817
            "NaN",
818
            "inf",
819
            "-inf",
820
            "9223372036854775807",
821
            "-9223372036854775808",
822
            "1e309",
823
            "0.0000000000000000000001",
824
        ] {
825
            let input = format!("translate({n})");
826
            assert_eq!(
827
                parse_parentheses(&input, &["translate"]),
828
                Ok(("translate", n))
829
            );
830
        }
831
        // A bare number has no brace at all.
832
        assert_eq!(
833
            parse_parentheses("9223372036854775807", &["translate"]),
834
            Err(ParenthesisParseError::NoOpeningBraceFound)
835
        );
836
    }
837

            
838
    #[test]
839
    fn parse_parentheses_unicode_stopword_and_payload() {
840
        // Braces are ASCII, so the byte offsets always land on char boundaries.
841
        assert_eq!(
842
            parse_parentheses("url(\u{1F600}.png)", &["url"]),
843
            Ok(("url", "\u{1F600}.png"))
844
        );
845
        assert_eq!(
846
            parse_parentheses("\u{FC}(\u{1F600})", &["\u{FC}"]),
847
            Ok(("\u{FC}", "\u{1F600}"))
848
        );
849
        assert_eq!(
850
            parse_parentheses("\u{1F600}(x)", &["url"]),
851
            Err(ParenthesisParseError::StopWordNotFound("\u{1F600}"))
852
        );
853
    }
854

            
855
    #[test]
856
    fn parse_parentheses_garbage_never_panics() {
857
        for garbage in ["(", ")", ")(", "()", "((((", "))))", "\0(\0)", "\u{FFFD}"] {
858
            // Either it parses or it errors — it must not panic, and any Ok
859
            // payload must be a substring of the (trimmed) input.
860
            if let Ok((_, inner)) = parse_parentheses(garbage, &["", "\u{FFFD}"]) {
861
                assert!(garbage.contains(inner));
862
            }
863
        }
864
    }
865

            
866
    #[test]
867
    fn parse_parentheses_extremely_long_input_does_not_hang() {
868
        let payload = "a".repeat(1_000_000);
869
        let input = format!("url({payload})");
870
        assert_eq!(
871
            parse_parentheses(&input, &["url"]),
872
            Ok(("url", payload.as_str()))
873
        );
874
    }
875

            
876
    #[test]
877
    fn parse_parentheses_deeply_nested_does_not_stack_overflow() {
878
        let inner = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
879
        let input = format!("abc({inner})");
880
        // find/rfind based, not recursive-descent.
881
        assert_eq!(
882
            parse_parentheses(&input, &["abc"]),
883
            Ok(("abc", inner.as_str()))
884
        );
885
    }
886

            
887
    // ---------------------------------------------------------------------
888
    // ParenthesisParseError <-> Owned (getters / round-trip)
889
    // ---------------------------------------------------------------------
890

            
891
    #[test]
892
    fn parenthesis_error_to_contained_maps_each_variant() {
893
        assert_eq!(
894
            ParenthesisParseError::UnclosedBraces.to_contained(),
895
            ParenthesisParseErrorOwned::UnclosedBraces
896
        );
897
        assert_eq!(
898
            ParenthesisParseError::NoOpeningBraceFound.to_contained(),
899
            ParenthesisParseErrorOwned::NoOpeningBraceFound
900
        );
901
        assert_eq!(
902
            ParenthesisParseError::NoClosingBraceFound.to_contained(),
903
            ParenthesisParseErrorOwned::NoClosingBraceFound
904
        );
905
        assert_eq!(
906
            ParenthesisParseError::EmptyInput.to_contained(),
907
            ParenthesisParseErrorOwned::EmptyInput
908
        );
909
        assert_eq!(
910
            ParenthesisParseError::StopWordNotFound("abc").to_contained(),
911
            ParenthesisParseErrorOwned::StopWordNotFound("abc".into())
912
        );
913
    }
914

            
915
    #[test]
916
    fn parenthesis_error_round_trips_through_owned() {
917
        let huge = "x".repeat(100_000);
918
        let cases = [
919
            ParenthesisParseError::UnclosedBraces,
920
            ParenthesisParseError::NoOpeningBraceFound,
921
            ParenthesisParseError::NoClosingBraceFound,
922
            ParenthesisParseError::EmptyInput,
923
            ParenthesisParseError::StopWordNotFound(""),
924
            ParenthesisParseError::StopWordNotFound("url"),
925
            ParenthesisParseError::StopWordNotFound("\u{1F600}\u{0301}"),
926
            ParenthesisParseError::StopWordNotFound("\0"),
927
            ParenthesisParseError::StopWordNotFound(huge.as_str()),
928
        ];
929
        for case in cases {
930
            let owned = case.to_contained();
931
            assert_eq!(owned.to_shared(), case, "round-trip must be lossless");
932
            // to_shared -> to_contained must also be stable.
933
            assert_eq!(owned.to_shared().to_contained(), owned);
934
        }
935
    }
936

            
937
    #[test]
938
    fn parenthesis_error_owned_to_shared_borrows_the_payload() {
939
        let owned = ParenthesisParseErrorOwned::StopWordNotFound("linear-gradient".into());
940
        match owned.to_shared() {
941
            ParenthesisParseError::StopWordNotFound(s) => assert_eq!(s, "linear-gradient"),
942
            other => panic!("expected StopWordNotFound, got {other:?}"),
943
        }
944
    }
945

            
946
    #[test]
947
    fn parenthesis_error_display_never_panics_on_extreme_payloads() {
948
        for e in [
949
            ParenthesisParseError::EmptyInput,
950
            ParenthesisParseError::StopWordNotFound(""),
951
            ParenthesisParseError::StopWordNotFound("\u{1F600}"),
952
        ] {
953
            assert!(!format!("{e}").is_empty());
954
        }
955
    }
956

            
957
    // ---------------------------------------------------------------------
958
    // strip_quotes (parser)
959
    // ---------------------------------------------------------------------
960

            
961
    #[test]
962
    fn strip_quotes_valid_minimal_positive_control() {
963
        assert_eq!(
964
            strip_quotes("\"Helvetica\""),
965
            Ok(QuoteStripped("Helvetica"))
966
        );
967
        assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
968
        // Empty quoted string is legal and yields an empty payload.
969
        assert_eq!(strip_quotes("\"\""), Ok(QuoteStripped("")));
970
        assert_eq!(strip_quotes("''"), Ok(QuoteStripped("")));
971
    }
972

            
973
    #[test]
974
    fn strip_quotes_empty_blank_and_unquoted_inputs_error() {
975
        assert_eq!(strip_quotes(""), Err(UnclosedQuotesError("")));
976
        assert_eq!(strip_quotes("   "), Err(UnclosedQuotesError("   ")));
977
        assert_eq!(strip_quotes("\t\n"), Err(UnclosedQuotesError("\t\n")));
978
        assert_eq!(
979
            strip_quotes("no-quotes"),
980
            Err(UnclosedQuotesError("no-quotes"))
981
        );
982
    }
983

            
984
    #[test]
985
    fn strip_quotes_mixed_quote_kinds_are_rejected() {
986
        assert_eq!(
987
            strip_quotes("\"Arial'"),
988
            Err(UnclosedQuotesError("\"Arial'"))
989
        );
990
        // A legitimate apostrophe inside a double-quoted name is *also* rejected.
991
        assert_eq!(
992
            strip_quotes("\"Bob's Font\""),
993
            Err(UnclosedQuotesError("\"Bob's Font\""))
994
        );
995
    }
996

            
997
    #[test]
998
    fn strip_quotes_unclosed_error_payload_is_asymmetric_between_branches() {
999
        // The single-quote branch reports the whole input...
        assert_eq!(
            strip_quotes("'unclosed"),
            Err(UnclosedQuotesError("'unclosed"))
        );
        assert_eq!(strip_quotes("'"), Err(UnclosedQuotesError("'")));
        // ...but the double-quote branch reports only the text *after* the quote,
        // losing the leading '"'. Pinned as-is; the two branches disagree.
        assert_eq!(
            strip_quotes("\"unclosed"),
            Err(UnclosedQuotesError("unclosed"))
        );
        assert_eq!(strip_quotes("\""), Err(UnclosedQuotesError("")));
    }
    #[test]
    fn strip_quotes_surrounding_whitespace_defeats_stripping() {
        // strip_quotes does not trim, so a padded input is "unclosed".
        assert_eq!(
            strip_quotes(" \"Arial\" "),
            Err(UnclosedQuotesError("Arial\" "))
        );
        assert_eq!(
            strip_quotes(" 'Arial' "),
            Err(UnclosedQuotesError(" 'Arial' "))
        );
        // Inner whitespace, however, is preserved exactly.
        assert_eq!(
            strip_quotes("\"  spaced  \""),
            Ok(QuoteStripped("  spaced  "))
        );
    }
    #[test]
    fn strip_quotes_trims_the_entire_trailing_quote_run() {
        // trim_end_matches strips *all* trailing quotes, not just one.
        assert_eq!(strip_quotes("\"\"\""), Ok(QuoteStripped("")));
        assert_eq!(strip_quotes("\"ab\"\"\""), Ok(QuoteStripped("ab")));
        // An interior quote survives, so the result can still contain a quote.
        assert_eq!(strip_quotes("\"a\"b\""), Ok(QuoteStripped("a\"b")));
    }
    #[test]
    fn strip_quotes_unicode_payload() {
        assert_eq!(
            strip_quotes("\"\u{1F600}\u{E9}\""),
            Ok(QuoteStripped("\u{1F600}\u{E9}"))
        );
        assert_eq!(
            strip_quotes("'e\u{0301}\u{4E2D}'"),
            Ok(QuoteStripped("e\u{0301}\u{4E2D}"))
        );
    }
    #[test]
    fn strip_quotes_boundary_number_strings() {
        for n in ["0", "-0", "NaN", "inf", "9223372036854775807", "1e309"] {
            assert_eq!(strip_quotes(&format!("\"{n}\"")), Ok(QuoteStripped(n)));
        }
    }
    #[test]
    fn strip_quotes_garbage_never_panics() {
        for garbage in ["\0", "\\", "`", "\u{FFFD}", "\"\0\"", "((\"))"] {
            let _ = strip_quotes(garbage);
        }
        assert_eq!(strip_quotes("\"\0\""), Ok(QuoteStripped("\0")));
    }
    #[test]
    fn strip_quotes_extremely_long_and_deeply_nested_inputs() {
        let payload = "a".repeat(1_000_000);
        let input = format!("\"{payload}\"");
        assert_eq!(strip_quotes(&input), Ok(QuoteStripped(payload.as_str())));
        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
        let quoted = format!("'{nested}'");
        assert_eq!(strip_quotes(&quoted), Ok(QuoteStripped(nested.as_str())));
    }
    #[test]
    fn strip_quotes_round_trips_quote_free_payloads() {
        for payload in [
            "Helvetica",
            "",
            "  spaced  ",
            "url(a,b)",
            "\u{1F600}",
            "0",
            "a\nb",
        ] {
            assert_eq!(
                strip_quotes(&format!("\"{payload}\"")),
                Ok(QuoteStripped(payload)),
                "double-quote round-trip"
            );
            assert_eq!(
                strip_quotes(&format!("'{payload}'")),
                Ok(QuoteStripped(payload)),
                "single-quote round-trip"
            );
        }
    }
    // ---------------------------------------------------------------------
    // CssImageParseError <-> Owned (getters / round-trip)
    // ---------------------------------------------------------------------
    #[test]
    fn css_image_error_round_trips_through_owned() {
        let huge = "x".repeat(100_000);
        for payload in ["", "a.png", "\u{1F600}\u{0301}", "\0", huge.as_str()] {
            let shared = CssImageParseError::UnclosedQuotes(payload);
            let owned = shared.to_contained();
            assert_eq!(
                owned,
                CssImageParseErrorOwned::UnclosedQuotes(payload.into())
            );
            assert_eq!(owned.to_shared(), shared, "round-trip must be lossless");
            assert_eq!(owned.to_shared().to_contained(), owned);
        }
    }
    #[test]
    fn css_image_error_display_includes_the_payload() {
        let e = CssImageParseError::UnclosedQuotes("\u{1F600}");
        assert!(format!("{e}").contains('\u{1F600}'));
        // Debug is routed through Display; it must not panic on an empty payload.
        assert!(!format!("{:?}", CssImageParseError::UnclosedQuotes("")).is_empty());
    }
    #[test]
    fn unclosed_quotes_error_converts_into_css_image_error() {
        let e: CssImageParseError<'_> = UnclosedQuotesError("bad").into();
        assert_eq!(e, CssImageParseError::UnclosedQuotes("bad"));
    }
    // ---------------------------------------------------------------------
    // parse_image (parser)
    // ---------------------------------------------------------------------
    #[test]
    fn parse_image_is_infallible_for_every_adversarial_input() {
        // The signature returns Result, but the body swallows the strip_quotes
        // error and falls back to the trimmed input — it can never be Err.
        let huge = "a".repeat(1_000_000);
        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
        for input in [
            "",
            "   ",
            "\t\n",
            "\0",
            "\"",
            "'",
            "\"mixed'",
            "no-quotes",
            "url(a.png)",
            "9223372036854775807",
            "NaN",
            "\u{1F600}",
            huge.as_str(),
            nested.as_str(),
        ] {
            assert!(
                parse_image(input).is_ok(),
                "parse_image({input:?}) must be Ok"
            );
        }
    }
    #[test]
    fn parse_image_valid_minimal_positive_control() {
        assert_eq!(parse_image("\"image.png\"").unwrap().as_str(), "image.png");
        assert_eq!(parse_image("'image.png'").unwrap().as_str(), "image.png");
        // Unquoted input is accepted and trimmed.
        assert_eq!(parse_image("  image.png  ").unwrap().as_str(), "image.png");
        assert_eq!(parse_image("").unwrap().as_str(), "");
        assert_eq!(parse_image("   ").unwrap().as_str(), "");
    }
    #[test]
    fn parse_image_quoted_payload_is_not_trimmed() {
        // The successful strip_quotes path preserves inner whitespace verbatim,
        // while the fallback path trims — the two paths differ.
        assert_eq!(parse_image("\"  a  \"").unwrap().as_str(), "  a  ");
        assert_eq!(parse_image("  a  ").unwrap().as_str(), "a");
    }
    #[test]
    fn parse_image_malformed_quotes_fall_back_to_the_raw_trimmed_input() {
        // Quotes are *retained* when stripping fails — an unbalanced quote is
        // silently accepted as part of the path rather than rejected.
        assert_eq!(parse_image("\"unclosed").unwrap().as_str(), "\"unclosed");
        assert_eq!(parse_image("'unclosed").unwrap().as_str(), "'unclosed");
        assert_eq!(parse_image("\"mixed'").unwrap().as_str(), "\"mixed'");
        // Padding around the quotes defeats strip_quotes, so the quotes survive.
        assert_eq!(parse_image("  \"a\"  ").unwrap().as_str(), "\"a\"");
    }
    #[test]
    fn parse_image_does_not_unwrap_url_functions() {
        // parse_image only strips quotes; it is not a url() parser.
        assert_eq!(parse_image("url(a.png)").unwrap().as_str(), "url(a.png)");
    }
    #[test]
    fn parse_image_unicode_and_extremely_long_inputs() {
        assert_eq!(
            parse_image("'\u{1F600}.png'").unwrap().as_str(),
            "\u{1F600}.png"
        );
        let payload = "a".repeat(1_000_000);
        let input = format!("\"{payload}\"");
        assert_eq!(parse_image(&input).unwrap().as_str().len(), 1_000_000);
    }
    #[test]
    fn parse_image_round_trips_quote_free_payloads() {
        for payload in ["a.png", "", "\u{1F600}", "some/deep/path.jpeg", "0"] {
            assert_eq!(
                parse_image(&format!("\"{payload}\"")).unwrap().as_str(),
                payload
            );
            assert_eq!(
                parse_image(&format!("'{payload}'")).unwrap().as_str(),
                payload
            );
        }
    }
}