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
32333
#[must_use] pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
15
32333
    split_string_by_char(input, ',')
16
32333
}
17

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

            
27
2373955
    for (idx, &ch) in input_bytes.iter().enumerate() {
28
1020136
        match ch {
29
35879
            b'(' => depth += 1,
30
45879
            b')' => depth -= 1,
31
1020136
            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
32
1020074
                if current_start < idx {
33
20048
                    items.push(&input[current_start..idx]);
34
1000040
                }
35
1020074
                current_start = idx + 1;
36
            }
37
1272123
            _ => {}
38
        }
39
    }
40

            
41
    // Add the last segment
42
860
    if current_start < input.len() {
43
848
        items.push(&input[current_start..]);
44
848
    }
45

            
46
860
    items
47
860
}
48

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

            
53
    'outer: loop {
54
322580
        let Some((skip_next_braces_result, character_was_found)) =
55
322722
            skip_next_braces(current_input, target_char)
56
        else {
57
142
            break 'outer;
58
        };
59
322580
        if character_was_found {
60
290383
            comma_separated_items.push(&current_input[..skip_next_braces_result]);
61
290383
            current_input = &current_input[(skip_next_braces_result + 1)..];
62
290383
        } else {
63
32197
            comma_separated_items.push(current_input);
64
32197
            break 'outer;
65
        }
66
    }
67

            
68
32339
    comma_separated_items
69
32339
}
70

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

            
77
322748
    if input.is_empty() {
78
145
        return None;
79
322603
    }
80

            
81
4877947
    for (idx, ch) in input.char_indices() {
82
4877947
        last_character = Some(idx);
83
4877947
        match ch {
84
190396
            '(' => {
85
190396
                depth += 1;
86
190396
            }
87
190289
            ')' => {
88
190289
                depth -= 1;
89
190289
            }
90
4497262
            c => {
91
4497262
                if c == target_char && depth == 0 {
92
290391
                    character_was_found = true;
93
290391
                    break;
94
4206871
                }
95
            }
96
        }
97
    }
98

            
99
322603
    last_character.map(|lc| (lc, character_was_found))
100
322748
}
101

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

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

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

            
148
impl ParenthesisParseErrorOwned {
149
39
    #[must_use] pub fn to_shared(&self) -> ParenthesisParseError<'_> {
150
39
        match self {
151
4
            Self::UnclosedBraces => ParenthesisParseError::UnclosedBraces,
152
            Self::NoOpeningBraceFound => {
153
5
                ParenthesisParseError::NoOpeningBraceFound
154
            }
155
            Self::NoClosingBraceFound => {
156
4
                ParenthesisParseError::NoClosingBraceFound
157
            }
158
19
            Self::StopWordNotFound(s) => {
159
19
                ParenthesisParseError::StopWordNotFound(s.as_str())
160
            }
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
565891
pub fn parse_parentheses<'a>(
196
565891
    input: &'a str,
197
565891
    stopwords: &[&'static str],
198
565891
) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
199
    use self::ParenthesisParseError::{EmptyInput, NoOpeningBraceFound, StopWordNotFound, NoClosingBraceFound};
200

            
201
565891
    let input = input.trim();
202
565891
    if input.is_empty() {
203
223
        return Err(EmptyInput);
204
565668
    }
205

            
206
565668
    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
207
33220
    let found_stopword = &input[..first_open_brace];
208

            
209
    // CSS does not allow for space between the ( and the stopword, so no .trim() here
210
33220
    let mut validated_stopword = None;
211
148473
    for stopword in stopwords {
212
147492
        if found_stopword == *stopword {
213
32239
            validated_stopword = Some(stopword);
214
32239
            break;
215
115253
        }
216
    }
217

            
218
33220
    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
219
32239
    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
220

            
221
32124
    Ok((
222
32124
        validated_stopword,
223
32124
        &input[(first_open_brace + 1)..last_closing_brace],
224
32124
    ))
225
565891
}
226

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

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

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

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

            
267
13537
    let first_double_quote = double_quote_iter.next();
268
13537
    let first_single_quote = single_quote_iter.next();
269
13537
    if first_double_quote.is_some() && first_single_quote.is_some() {
270
8
        return Err(UnclosedQuotesError(input));
271
13529
    }
272
13529
    if let Some(quote_contents) = first_double_quote {
273
788
        if !quote_contents.ends_with('"') {
274
9
            return Err(UnclosedQuotesError(quote_contents));
275
779
        }
276
779
        Ok(QuoteStripped(quote_contents.trim_end_matches('"')))
277
12741
    } else if let Some(quote_contents) = first_single_quote {
278
965
        if !quote_contents.ends_with('\'') {
279
8
            return Err(UnclosedQuotesError(input));
280
957
        }
281
957
        Ok(QuoteStripped(quote_contents.trim_end_matches('\'')))
282
    } else {
283
11776
        Err(UnclosedQuotesError(input))
284
    }
285
13537
}
286

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
419
    // ---------------------------------------------------------------------
420
    // skip_next_braces (private, parser)
421
    // ---------------------------------------------------------------------
422

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

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

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

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

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

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

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

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

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

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

            
510
    // ---------------------------------------------------------------------
511
    // split_string_by_char (private, other)
512
    // ---------------------------------------------------------------------
513

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

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

            
530
    #[test]
531
    fn split_string_by_char_paren_separator_never_splits() {
532
        assert_eq!(split_string_by_char("a(b)c", '('), vec!["a(b)c"]);
533
        assert_eq!(split_string_by_char("a(b)c", ')'), vec!["a(b)c"]);
534
    }
535

            
536
    // ---------------------------------------------------------------------
537
    // split_string_respect_comma (other)
538
    // ---------------------------------------------------------------------
539

            
540
    #[test]
541
    fn split_comma_empty_and_separator_only_inputs() {
542
        assert!(split_string_respect_comma("").is_empty());
543
        assert_eq!(split_string_respect_comma(","), vec![""]);
544
        assert_eq!(split_string_respect_comma(",,"), vec!["", ""]);
545
        assert_eq!(split_string_respect_comma("a,,b"), vec!["a", "", "b"]);
546
    }
547

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

            
556
    #[test]
557
    fn split_comma_unbalanced_closing_paren_swallows_separators() {
558
        // Stray ')' => depth goes negative => nothing splits. No panic.
559
        assert_eq!(split_string_respect_comma("a),b"), vec!["a),b"]);
560
        assert_eq!(split_string_respect_comma("a(b,c"), vec!["a(b,c"]);
561
    }
562

            
563
    #[test]
564
    fn split_comma_respects_balanced_nesting() {
565
        assert_eq!(
566
            split_string_respect_comma("rgba(1,2,3),url(a,b)"),
567
            vec!["rgba(1,2,3)", "url(a,b)"]
568
        );
569
        assert_eq!(
570
            split_string_respect_comma("f(g(h(1,2),3),4),5"),
571
            vec!["f(g(h(1,2),3),4)", "5"]
572
        );
573
    }
574

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

            
588
    #[test]
589
    fn split_comma_garbage_input_never_panics() {
590
        for garbage in [
591
            "\0", "\u{FFFD}", ";;;", "((((", "))))", "()", ",()", "()," , "\\\"'`",
592
            "\u{200B},\u{200B}", "--,--", "\t,\n,\r",
593
        ] {
594
            let parts = split_string_respect_comma(garbage);
595
            // Every returned slice must be a real substring of the input.
596
            for p in &parts {
597
                assert!(garbage.contains(p));
598
            }
599
        }
600
    }
601

            
602
    #[test]
603
    fn split_comma_extremely_long_inputs_do_not_hang() {
604
        let no_comma = "a".repeat(1_000_000);
605
        assert_eq!(split_string_respect_comma(&no_comma), vec![no_comma.as_str()]);
606

            
607
        let all_commas = ",".repeat(100_000);
608
        let parts = split_string_respect_comma(&all_commas);
609
        assert_eq!(parts.len(), 100_000);
610
        assert!(parts.iter().all(|p| p.is_empty()));
611
    }
612

            
613
    #[test]
614
    fn split_comma_deeply_nested_does_not_stack_overflow() {
615
        let nested = format!("{}1,2{}", "(".repeat(10_000), ")".repeat(10_000));
616
        // Every comma is nested => a single segment.
617
        assert_eq!(split_string_respect_comma(&nested), vec![nested.as_str()]);
618
    }
619

            
620
    #[test]
621
    fn split_comma_round_trips_via_join_when_no_trailing_separator() {
622
        for input in [
623
            "a,b,c",
624
            "one, two, three",
625
            "rgba(1,2,3),x",
626
            "a,,b",
627
            ",a",
628
            "rgb(0,0,0)",
629
        ] {
630
            assert_eq!(split_string_respect_comma(input).join(","), input);
631
        }
632
    }
633

            
634
    // ---------------------------------------------------------------------
635
    // split_string_respect_whitespace (other)
636
    // ---------------------------------------------------------------------
637

            
638
    #[test]
639
    fn split_whitespace_empty_and_blank_inputs_yield_nothing() {
640
        assert!(split_string_respect_whitespace("").is_empty());
641
        assert!(split_string_respect_whitespace("   ").is_empty());
642
        assert!(split_string_respect_whitespace("\t\n\r").is_empty());
643
    }
644

            
645
    #[test]
646
    fn split_whitespace_valid_minimal_and_run_collapsing() {
647
        assert_eq!(
648
            split_string_respect_whitespace("translateX(10px) rotate(90deg)"),
649
            vec!["translateX(10px)", "rotate(90deg)"]
650
        );
651
        assert_eq!(split_string_respect_whitespace("  a\t\tb\n"), vec!["a", "b"]);
652
    }
653

            
654
    #[test]
655
    fn split_whitespace_respects_balanced_nesting() {
656
        assert_eq!(
657
            split_string_respect_whitespace("translate( 10px , 20px ) scale(2)"),
658
            vec!["translate( 10px , 20px )", "scale(2)"]
659
        );
660
    }
661

            
662
    #[test]
663
    fn split_whitespace_unbalanced_closing_paren_disables_splitting() {
664
        assert_eq!(split_string_respect_whitespace("a) b"), vec!["a) b"]);
665
        assert_eq!(split_string_respect_whitespace("a( b"), vec!["a( b"]);
666
    }
667

            
668
    #[test]
669
    fn split_whitespace_unicode_is_split_on_ascii_bytes_only() {
670
        // Scanning is byte-wise; UTF-8 continuation bytes are >= 0x80 so they can
671
        // never be mistaken for a space/paren => slices stay on char boundaries.
672
        assert_eq!(
673
            split_string_respect_whitespace("h\u{E9}llo w\u{F6}rld \u{1F600}"),
674
            vec!["h\u{E9}llo", "w\u{F6}rld", "\u{1F600}"]
675
        );
676
        // U+00A0 NBSP is *not* an ASCII space => not a separator.
677
        assert_eq!(
678
            split_string_respect_whitespace("a\u{A0}b"),
679
            vec!["a\u{A0}b"]
680
        );
681
    }
682

            
683
    #[test]
684
    fn split_whitespace_garbage_input_never_panics() {
685
        for garbage in ["\0", "((((", "))))", ")(", "\u{FFFD} \u{FFFD}", "  ) (  "] {
686
            for p in &split_string_respect_whitespace(garbage) {
687
                assert!(garbage.contains(p));
688
            }
689
        }
690
    }
691

            
692
    #[test]
693
    fn split_whitespace_extremely_long_inputs_do_not_hang() {
694
        let blanks = " ".repeat(1_000_000);
695
        assert!(split_string_respect_whitespace(&blanks).is_empty());
696

            
697
        let word = "a".repeat(1_000_000);
698
        assert_eq!(split_string_respect_whitespace(&word), vec![word.as_str()]);
699
    }
700

            
701
    #[test]
702
    fn split_whitespace_deeply_nested_does_not_stack_overflow() {
703
        let nested = format!("{}a{}", "(".repeat(10_000), ")".repeat(10_000));
704
        let input = format!("{nested} z");
705
        assert_eq!(
706
            split_string_respect_whitespace(&input),
707
            vec![nested.as_str(), "z"]
708
        );
709
    }
710

            
711
    // ---------------------------------------------------------------------
712
    // parse_parentheses (parser)
713
    // ---------------------------------------------------------------------
714

            
715
    #[test]
716
    fn parse_parentheses_empty_and_whitespace_only_input() {
717
        assert_eq!(
718
            parse_parentheses("", &["url"]),
719
            Err(ParenthesisParseError::EmptyInput)
720
        );
721
        assert_eq!(
722
            parse_parentheses("   ", &["url"]),
723
            Err(ParenthesisParseError::EmptyInput)
724
        );
725
        assert_eq!(
726
            parse_parentheses("\t\n", &["url"]),
727
            Err(ParenthesisParseError::EmptyInput)
728
        );
729
        // Empty stopword list can never validate.
730
        assert_eq!(
731
            parse_parentheses("url(a)", &[]),
732
            Err(ParenthesisParseError::StopWordNotFound("url"))
733
        );
734
    }
735

            
736
    #[test]
737
    fn parse_parentheses_valid_minimal_positive_control() {
738
        assert_eq!(parse_parentheses("a(b)", &["a"]), Ok(("a", "b")));
739
        assert_eq!(parse_parentheses("abc()", &["abc"]), Ok(("abc", "")));
740
        assert_eq!(
741
            parse_parentheses("abc(def(g))", &["abc", "def"]),
742
            Ok(("abc", "def(g)"))
743
        );
744
    }
745

            
746
    #[test]
747
    fn parse_parentheses_missing_braces_and_stopword() {
748
        assert_eq!(
749
            parse_parentheses("abc", &["abc"]),
750
            Err(ParenthesisParseError::NoOpeningBraceFound)
751
        );
752
        assert_eq!(
753
            parse_parentheses("url(image.png", &["url"]),
754
            Err(ParenthesisParseError::NoClosingBraceFound)
755
        );
756
        assert_eq!(
757
            parse_parentheses("rgba(1,2,3,4)", &["rgb"]),
758
            Err(ParenthesisParseError::StopWordNotFound("rgba"))
759
        );
760
    }
761

            
762
    #[test]
763
    fn parse_parentheses_stopword_must_directly_abut_the_brace() {
764
        // CSS forbids whitespace between the function name and '('; the inner
765
        // whitespace survives the outer trim, so this must not validate.
766
        assert_eq!(
767
            parse_parentheses("url (x)", &["url"]),
768
            Err(ParenthesisParseError::StopWordNotFound("url "))
769
        );
770
        assert_eq!(
771
            parse_parentheses("URL(x)", &["url"]),
772
            Err(ParenthesisParseError::StopWordNotFound("URL"))
773
        );
774
    }
775

            
776
    #[test]
777
    fn parse_parentheses_uses_last_closing_brace_and_drops_trailing_junk() {
778
        // rfind(')') => everything after the *last* ')' is silently discarded.
779
        assert_eq!(parse_parentheses("url(a)b)", &["url"]), Ok(("url", "a)b")));
780
        assert_eq!(
781
            parse_parentheses("url(a);garbage", &["url"]),
782
            Ok(("url", "a"))
783
        );
784
        // Outer whitespace is trimmed, inner whitespace is preserved verbatim.
785
        assert_eq!(
786
            parse_parentheses("  rgb( 1 )  ", &["rgb", "rgba"]),
787
            Ok(("rgb", " 1 "))
788
        );
789
    }
790

            
791
    #[test]
792
    fn parse_parentheses_boundary_number_strings_pass_through_verbatim() {
793
        for n in [
794
            "0",
795
            "-0",
796
            "NaN",
797
            "inf",
798
            "-inf",
799
            "9223372036854775807",
800
            "-9223372036854775808",
801
            "1e309",
802
            "0.0000000000000000000001",
803
        ] {
804
            let input = format!("translate({n})");
805
            assert_eq!(parse_parentheses(&input, &["translate"]), Ok(("translate", n)));
806
        }
807
        // A bare number has no brace at all.
808
        assert_eq!(
809
            parse_parentheses("9223372036854775807", &["translate"]),
810
            Err(ParenthesisParseError::NoOpeningBraceFound)
811
        );
812
    }
813

            
814
    #[test]
815
    fn parse_parentheses_unicode_stopword_and_payload() {
816
        // Braces are ASCII, so the byte offsets always land on char boundaries.
817
        assert_eq!(
818
            parse_parentheses("url(\u{1F600}.png)", &["url"]),
819
            Ok(("url", "\u{1F600}.png"))
820
        );
821
        assert_eq!(
822
            parse_parentheses("\u{FC}(\u{1F600})", &["\u{FC}"]),
823
            Ok(("\u{FC}", "\u{1F600}"))
824
        );
825
        assert_eq!(
826
            parse_parentheses("\u{1F600}(x)", &["url"]),
827
            Err(ParenthesisParseError::StopWordNotFound("\u{1F600}"))
828
        );
829
    }
830

            
831
    #[test]
832
    fn parse_parentheses_garbage_never_panics() {
833
        for garbage in ["(", ")", ")(", "()", "((((", "))))", "\0(\0)", "\u{FFFD}"] {
834
            // Either it parses or it errors — it must not panic, and any Ok
835
            // payload must be a substring of the (trimmed) input.
836
            if let Ok((_, inner)) = parse_parentheses(garbage, &["", "\u{FFFD}"]) {
837
                assert!(garbage.contains(inner));
838
            }
839
        }
840
    }
841

            
842
    #[test]
843
    fn parse_parentheses_extremely_long_input_does_not_hang() {
844
        let payload = "a".repeat(1_000_000);
845
        let input = format!("url({payload})");
846
        assert_eq!(
847
            parse_parentheses(&input, &["url"]),
848
            Ok(("url", payload.as_str()))
849
        );
850
    }
851

            
852
    #[test]
853
    fn parse_parentheses_deeply_nested_does_not_stack_overflow() {
854
        let inner = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
855
        let input = format!("abc({inner})");
856
        // find/rfind based, not recursive-descent.
857
        assert_eq!(parse_parentheses(&input, &["abc"]), Ok(("abc", inner.as_str())));
858
    }
859

            
860
    // ---------------------------------------------------------------------
861
    // ParenthesisParseError <-> Owned (getters / round-trip)
862
    // ---------------------------------------------------------------------
863

            
864
    #[test]
865
    fn parenthesis_error_to_contained_maps_each_variant() {
866
        assert_eq!(
867
            ParenthesisParseError::UnclosedBraces.to_contained(),
868
            ParenthesisParseErrorOwned::UnclosedBraces
869
        );
870
        assert_eq!(
871
            ParenthesisParseError::NoOpeningBraceFound.to_contained(),
872
            ParenthesisParseErrorOwned::NoOpeningBraceFound
873
        );
874
        assert_eq!(
875
            ParenthesisParseError::NoClosingBraceFound.to_contained(),
876
            ParenthesisParseErrorOwned::NoClosingBraceFound
877
        );
878
        assert_eq!(
879
            ParenthesisParseError::EmptyInput.to_contained(),
880
            ParenthesisParseErrorOwned::EmptyInput
881
        );
882
        assert_eq!(
883
            ParenthesisParseError::StopWordNotFound("abc").to_contained(),
884
            ParenthesisParseErrorOwned::StopWordNotFound("abc".into())
885
        );
886
    }
887

            
888
    #[test]
889
    fn parenthesis_error_round_trips_through_owned() {
890
        let huge = "x".repeat(100_000);
891
        let cases = [
892
            ParenthesisParseError::UnclosedBraces,
893
            ParenthesisParseError::NoOpeningBraceFound,
894
            ParenthesisParseError::NoClosingBraceFound,
895
            ParenthesisParseError::EmptyInput,
896
            ParenthesisParseError::StopWordNotFound(""),
897
            ParenthesisParseError::StopWordNotFound("url"),
898
            ParenthesisParseError::StopWordNotFound("\u{1F600}\u{0301}"),
899
            ParenthesisParseError::StopWordNotFound("\0"),
900
            ParenthesisParseError::StopWordNotFound(huge.as_str()),
901
        ];
902
        for case in cases {
903
            let owned = case.to_contained();
904
            assert_eq!(owned.to_shared(), case, "round-trip must be lossless");
905
            // to_shared -> to_contained must also be stable.
906
            assert_eq!(owned.to_shared().to_contained(), owned);
907
        }
908
    }
909

            
910
    #[test]
911
    fn parenthesis_error_owned_to_shared_borrows_the_payload() {
912
        let owned = ParenthesisParseErrorOwned::StopWordNotFound("linear-gradient".into());
913
        match owned.to_shared() {
914
            ParenthesisParseError::StopWordNotFound(s) => assert_eq!(s, "linear-gradient"),
915
            other => panic!("expected StopWordNotFound, got {other:?}"),
916
        }
917
    }
918

            
919
    #[test]
920
    fn parenthesis_error_display_never_panics_on_extreme_payloads() {
921
        for e in [
922
            ParenthesisParseError::EmptyInput,
923
            ParenthesisParseError::StopWordNotFound(""),
924
            ParenthesisParseError::StopWordNotFound("\u{1F600}"),
925
        ] {
926
            assert!(!format!("{e}").is_empty());
927
        }
928
    }
929

            
930
    // ---------------------------------------------------------------------
931
    // strip_quotes (parser)
932
    // ---------------------------------------------------------------------
933

            
934
    #[test]
935
    fn strip_quotes_valid_minimal_positive_control() {
936
        assert_eq!(strip_quotes("\"Helvetica\""), Ok(QuoteStripped("Helvetica")));
937
        assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
938
        // Empty quoted string is legal and yields an empty payload.
939
        assert_eq!(strip_quotes("\"\""), Ok(QuoteStripped("")));
940
        assert_eq!(strip_quotes("''"), Ok(QuoteStripped("")));
941
    }
942

            
943
    #[test]
944
    fn strip_quotes_empty_blank_and_unquoted_inputs_error() {
945
        assert_eq!(strip_quotes(""), Err(UnclosedQuotesError("")));
946
        assert_eq!(strip_quotes("   "), Err(UnclosedQuotesError("   ")));
947
        assert_eq!(strip_quotes("\t\n"), Err(UnclosedQuotesError("\t\n")));
948
        assert_eq!(strip_quotes("no-quotes"), Err(UnclosedQuotesError("no-quotes")));
949
    }
950

            
951
    #[test]
952
    fn strip_quotes_mixed_quote_kinds_are_rejected() {
953
        assert_eq!(strip_quotes("\"Arial'"), Err(UnclosedQuotesError("\"Arial'")));
954
        // A legitimate apostrophe inside a double-quoted name is *also* rejected.
955
        assert_eq!(
956
            strip_quotes("\"Bob's Font\""),
957
            Err(UnclosedQuotesError("\"Bob's Font\""))
958
        );
959
    }
960

            
961
    #[test]
962
    fn strip_quotes_unclosed_error_payload_is_asymmetric_between_branches() {
963
        // The single-quote branch reports the whole input...
964
        assert_eq!(strip_quotes("'unclosed"), Err(UnclosedQuotesError("'unclosed")));
965
        assert_eq!(strip_quotes("'"), Err(UnclosedQuotesError("'")));
966
        // ...but the double-quote branch reports only the text *after* the quote,
967
        // losing the leading '"'. Pinned as-is; the two branches disagree.
968
        assert_eq!(strip_quotes("\"unclosed"), Err(UnclosedQuotesError("unclosed")));
969
        assert_eq!(strip_quotes("\""), Err(UnclosedQuotesError("")));
970
    }
971

            
972
    #[test]
973
    fn strip_quotes_surrounding_whitespace_defeats_stripping() {
974
        // strip_quotes does not trim, so a padded input is "unclosed".
975
        assert_eq!(
976
            strip_quotes(" \"Arial\" "),
977
            Err(UnclosedQuotesError("Arial\" "))
978
        );
979
        assert_eq!(
980
            strip_quotes(" 'Arial' "),
981
            Err(UnclosedQuotesError(" 'Arial' "))
982
        );
983
        // Inner whitespace, however, is preserved exactly.
984
        assert_eq!(strip_quotes("\"  spaced  \""), Ok(QuoteStripped("  spaced  ")));
985
    }
986

            
987
    #[test]
988
    fn strip_quotes_trims_the_entire_trailing_quote_run() {
989
        // trim_end_matches strips *all* trailing quotes, not just one.
990
        assert_eq!(strip_quotes("\"\"\""), Ok(QuoteStripped("")));
991
        assert_eq!(strip_quotes("\"ab\"\"\""), Ok(QuoteStripped("ab")));
992
        // An interior quote survives, so the result can still contain a quote.
993
        assert_eq!(strip_quotes("\"a\"b\""), Ok(QuoteStripped("a\"b")));
994
    }
995

            
996
    #[test]
997
    fn strip_quotes_unicode_payload() {
998
        assert_eq!(
999
            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
            );
        }
    }
}