Skip to main content

azul_css/props/basic/
parse.rs

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
7use 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] pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
15    split_string_by_char(input, ',')
16}
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#[must_use] pub fn split_string_respect_whitespace(input: &str) -> Vec<&str> {
22    let mut items = Vec::<&str>::new();
23    let mut current_start = 0;
24    let mut depth = 0;
25    let input_bytes = input.as_bytes();
26
27    for (idx, &ch) in input_bytes.iter().enumerate() {
28        match ch {
29            b'(' => depth += 1,
30            b')' => depth -= 1,
31            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
32                if current_start < idx {
33                    items.push(&input[current_start..idx]);
34                }
35                current_start = idx + 1;
36            }
37            _ => {}
38        }
39    }
40
41    // Add the last segment
42    if current_start < input.len() {
43        items.push(&input[current_start..]);
44    }
45
46    items
47}
48
49fn split_string_by_char(input: &str, target_char: char) -> Vec<&str> {
50    let mut comma_separated_items = Vec::<&str>::new();
51    let mut current_input = input;
52
53    'outer: loop {
54        let Some((skip_next_braces_result, character_was_found)) =
55            skip_next_braces(current_input, target_char)
56        else {
57            break 'outer;
58        };
59        if character_was_found {
60            comma_separated_items.push(&current_input[..skip_next_braces_result]);
61            current_input = &current_input[(skip_next_braces_result + 1)..];
62        } else {
63            comma_separated_items.push(current_input);
64            break 'outer;
65        }
66    }
67
68    comma_separated_items
69}
70
71/// Given a string, returns how many characters need to be skipped
72fn skip_next_braces(input: &str, target_char: char) -> Option<(usize, bool)> {
73    let mut depth = 0;
74    let mut last_character: Option<usize> = None;
75    let mut character_was_found = false;
76
77    if input.is_empty() {
78        return None;
79    }
80
81    for (idx, ch) in input.char_indices() {
82        last_character = Some(idx);
83        match ch {
84            '(' => {
85                depth += 1;
86            }
87            ')' => {
88                depth -= 1;
89            }
90            c => {
91                if c == target_char && depth == 0 {
92                    character_was_found = true;
93                    break;
94                }
95            }
96        }
97    }
98
99    last_character.map(|lc| (lc, character_was_found))
100}
101
102#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
103pub enum ParenthesisParseError<'a> {
104    UnclosedBraces,
105    NoOpeningBraceFound,
106    NoClosingBraceFound,
107    StopWordNotFound(&'a str),
108    EmptyInput,
109}
110
111impl_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)]
122pub enum ParenthesisParseErrorOwned {
123    UnclosedBraces,
124    NoOpeningBraceFound,
125    NoClosingBraceFound,
126    StopWordNotFound(AzString),
127    EmptyInput,
128}
129
130impl ParenthesisParseError<'_> {
131    #[must_use] pub fn to_contained(&self) -> ParenthesisParseErrorOwned {
132        match self {
133            ParenthesisParseError::UnclosedBraces => ParenthesisParseErrorOwned::UnclosedBraces,
134            ParenthesisParseError::NoOpeningBraceFound => {
135                ParenthesisParseErrorOwned::NoOpeningBraceFound
136            }
137            ParenthesisParseError::NoClosingBraceFound => {
138                ParenthesisParseErrorOwned::NoClosingBraceFound
139            }
140            ParenthesisParseError::StopWordNotFound(s) => {
141                ParenthesisParseErrorOwned::StopWordNotFound((*s).to_string().into())
142            }
143            ParenthesisParseError::EmptyInput => ParenthesisParseErrorOwned::EmptyInput,
144        }
145    }
146}
147
148impl ParenthesisParseErrorOwned {
149    #[must_use] pub fn to_shared(&self) -> ParenthesisParseError<'_> {
150        match self {
151            Self::UnclosedBraces => ParenthesisParseError::UnclosedBraces,
152            Self::NoOpeningBraceFound => {
153                ParenthesisParseError::NoOpeningBraceFound
154            }
155            Self::NoClosingBraceFound => {
156                ParenthesisParseError::NoClosingBraceFound
157            }
158            Self::StopWordNotFound(s) => {
159                ParenthesisParseError::StopWordNotFound(s.as_str())
160            }
161            Self::EmptyInput => ParenthesisParseError::EmptyInput,
162        }
163    }
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.
195pub fn parse_parentheses<'a>(
196    input: &'a str,
197    stopwords: &[&'static str],
198) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
199    use self::ParenthesisParseError::{EmptyInput, NoOpeningBraceFound, StopWordNotFound, NoClosingBraceFound};
200
201    let input = input.trim();
202    if input.is_empty() {
203        return Err(EmptyInput);
204    }
205
206    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
207    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    let mut validated_stopword = None;
211    for stopword in stopwords {
212        if found_stopword == *stopword {
213            validated_stopword = Some(stopword);
214            break;
215        }
216    }
217
218    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
219    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
220
221    Ok((
222        validated_stopword,
223        &input[(first_open_brace + 1)..last_closing_brace],
224    ))
225}
226
227/// String has unbalanced `'` or `"` quotation marks
228#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
229pub struct UnclosedQuotesError<'a>(pub &'a str);
230
231impl<'a> From<UnclosedQuotesError<'a>> for CssImageParseError<'a> {
232    fn from(err: UnclosedQuotesError<'a>) -> Self {
233        CssImageParseError::UnclosedQuotes(err.0)
234    }
235}
236
237/// A string that has been stripped of the beginning and ending quote
238#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
239pub 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.
261pub fn strip_quotes(input: &str) -> Result<QuoteStripped<'_>, UnclosedQuotesError<'_>> {
262    let mut double_quote_iter = input.splitn(2, '"');
263    double_quote_iter.next();
264    let mut single_quote_iter = input.splitn(2, '\'');
265    single_quote_iter.next();
266
267    let first_double_quote = double_quote_iter.next();
268    let first_single_quote = single_quote_iter.next();
269    if first_double_quote.is_some() && first_single_quote.is_some() {
270        return Err(UnclosedQuotesError(input));
271    }
272    if let Some(quote_contents) = first_double_quote {
273        if !quote_contents.ends_with('"') {
274            return Err(UnclosedQuotesError(quote_contents));
275        }
276        Ok(QuoteStripped(quote_contents.trim_end_matches('"')))
277    } else if let Some(quote_contents) = first_single_quote {
278        if !quote_contents.ends_with('\'') {
279            return Err(UnclosedQuotesError(input));
280        }
281        Ok(QuoteStripped(quote_contents.trim_end_matches('\'')))
282    } else {
283        Err(UnclosedQuotesError(input))
284    }
285}
286
287#[derive(Copy, Clone, PartialEq, Eq)]
288pub enum CssImageParseError<'a> {
289    UnclosedQuotes(&'a str),
290}
291
292impl_debug_as_display!(CssImageParseError<'a>);
293impl_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)]
300pub enum CssImageParseErrorOwned {
301    UnclosedQuotes(AzString),
302}
303
304impl CssImageParseError<'_> {
305    /// Converts to the owned variant.
306    #[must_use] pub fn to_contained(&self) -> CssImageParseErrorOwned {
307        match self {
308            CssImageParseError::UnclosedQuotes(s) => {
309                CssImageParseErrorOwned::UnclosedQuotes((*s).to_string().into())
310            }
311        }
312    }
313}
314
315impl CssImageParseErrorOwned {
316    /// Converts to the borrowed variant.
317    #[must_use] pub fn to_shared(&self) -> CssImageParseError<'_> {
318        match self {
319            Self::UnclosedQuotes(s) => {
320                CssImageParseError::UnclosedQuotes(s.as_str())
321            }
322        }
323    }
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.
331pub fn parse_image(input: &str) -> Result<AzString, CssImageParseError<'_>> {
332    Ok(strip_quotes(input).map_or_else(|_| input.trim().into(), |stripped| stripped.0.into()))
333}
334
335#[cfg(all(test, feature = "parser"))]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_strip_quotes() {
341        assert_eq!(strip_quotes("'hello'").unwrap(), QuoteStripped("hello"));
342        assert_eq!(strip_quotes("\"world\"").unwrap(), QuoteStripped("world"));
343        assert_eq!(
344            strip_quotes("\"  spaced  \"").unwrap(),
345            QuoteStripped("  spaced  ")
346        );
347        assert!(strip_quotes("'unclosed").is_err());
348        assert!(strip_quotes("\"mismatched'").is_err());
349        assert!(strip_quotes("no-quotes").is_err());
350    }
351
352    #[test]
353    fn test_parse_parentheses() {
354        assert_eq!(
355            parse_parentheses("url(image.png)", &["url"]),
356            Ok(("url", "image.png"))
357        );
358        assert_eq!(
359            parse_parentheses("linear-gradient(red, blue)", &["linear-gradient"]),
360            Ok(("linear-gradient", "red, blue"))
361        );
362        assert_eq!(
363            parse_parentheses("var(--my-var, 10px)", &["var"]),
364            Ok(("var", "--my-var, 10px"))
365        );
366        assert_eq!(
367            parse_parentheses("  rgb( 255, 0, 0 )  ", &["rgb", "rgba"]),
368            Ok(("rgb", " 255, 0, 0 "))
369        );
370    }
371
372    #[test]
373    fn test_parse_parentheses_errors() {
374        // Stopword not found
375        assert!(parse_parentheses("rgba(255,0,0,1)", &["rgb"]).is_err());
376        // No opening brace
377        assert!(parse_parentheses("url'image.png'", &["url"]).is_err());
378        // No closing brace
379        assert!(parse_parentheses("url(image.png", &["url"]).is_err());
380    }
381
382    #[test]
383    fn test_split_string_respect_comma() {
384        // Simple case
385        let simple = "one, two, three";
386        assert_eq!(
387            split_string_respect_comma(simple),
388            vec!["one", " two", " three"]
389        );
390
391        // With parentheses
392        let with_parens = "rgba(255, 0, 0, 1), #ff00ff";
393        assert_eq!(
394            split_string_respect_comma(with_parens),
395            vec!["rgba(255, 0, 0, 1)", " #ff00ff"]
396        );
397
398        // Multiple parentheses
399        let multi_parens =
400            "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)), url(image.png)";
401        assert_eq!(
402            split_string_respect_comma(multi_parens),
403            vec![
404                "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))",
405                " url(image.png)"
406            ]
407        );
408
409        // No commas
410        let no_commas = "rgb(0,0,0)";
411        assert_eq!(split_string_respect_comma(no_commas), vec!["rgb(0,0,0)"]);
412    }
413}
414
415#[cfg(test)]
416mod 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}\""),
1000            Ok(QuoteStripped("\u{1F600}\u{E9}"))
1001        );
1002        assert_eq!(
1003            strip_quotes("'e\u{0301}\u{4E2D}'"),
1004            Ok(QuoteStripped("e\u{0301}\u{4E2D}"))
1005        );
1006    }
1007
1008    #[test]
1009    fn strip_quotes_boundary_number_strings() {
1010        for n in ["0", "-0", "NaN", "inf", "9223372036854775807", "1e309"] {
1011            assert_eq!(strip_quotes(&format!("\"{n}\"")), Ok(QuoteStripped(n)));
1012        }
1013    }
1014
1015    #[test]
1016    fn strip_quotes_garbage_never_panics() {
1017        for garbage in ["\0", "\\", "`", "\u{FFFD}", "\"\0\"", "((\"))"] {
1018            let _ = strip_quotes(garbage);
1019        }
1020        assert_eq!(strip_quotes("\"\0\""), Ok(QuoteStripped("\0")));
1021    }
1022
1023    #[test]
1024    fn strip_quotes_extremely_long_and_deeply_nested_inputs() {
1025        let payload = "a".repeat(1_000_000);
1026        let input = format!("\"{payload}\"");
1027        assert_eq!(strip_quotes(&input), Ok(QuoteStripped(payload.as_str())));
1028
1029        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
1030        let quoted = format!("'{nested}'");
1031        assert_eq!(strip_quotes(&quoted), Ok(QuoteStripped(nested.as_str())));
1032    }
1033
1034    #[test]
1035    fn strip_quotes_round_trips_quote_free_payloads() {
1036        for payload in [
1037            "Helvetica",
1038            "",
1039            "  spaced  ",
1040            "url(a,b)",
1041            "\u{1F600}",
1042            "0",
1043            "a\nb",
1044        ] {
1045            assert_eq!(
1046                strip_quotes(&format!("\"{payload}\"")),
1047                Ok(QuoteStripped(payload)),
1048                "double-quote round-trip"
1049            );
1050            assert_eq!(
1051                strip_quotes(&format!("'{payload}'")),
1052                Ok(QuoteStripped(payload)),
1053                "single-quote round-trip"
1054            );
1055        }
1056    }
1057
1058    // ---------------------------------------------------------------------
1059    // CssImageParseError <-> Owned (getters / round-trip)
1060    // ---------------------------------------------------------------------
1061
1062    #[test]
1063    fn css_image_error_round_trips_through_owned() {
1064        let huge = "x".repeat(100_000);
1065        for payload in ["", "a.png", "\u{1F600}\u{0301}", "\0", huge.as_str()] {
1066            let shared = CssImageParseError::UnclosedQuotes(payload);
1067            let owned = shared.to_contained();
1068            assert_eq!(owned, CssImageParseErrorOwned::UnclosedQuotes(payload.into()));
1069            assert_eq!(owned.to_shared(), shared, "round-trip must be lossless");
1070            assert_eq!(owned.to_shared().to_contained(), owned);
1071        }
1072    }
1073
1074    #[test]
1075    fn css_image_error_display_includes_the_payload() {
1076        let e = CssImageParseError::UnclosedQuotes("\u{1F600}");
1077        assert!(format!("{e}").contains('\u{1F600}'));
1078        // Debug is routed through Display; it must not panic on an empty payload.
1079        assert!(!format!("{:?}", CssImageParseError::UnclosedQuotes("")).is_empty());
1080    }
1081
1082    #[test]
1083    fn unclosed_quotes_error_converts_into_css_image_error() {
1084        let e: CssImageParseError<'_> = UnclosedQuotesError("bad").into();
1085        assert_eq!(e, CssImageParseError::UnclosedQuotes("bad"));
1086    }
1087
1088    // ---------------------------------------------------------------------
1089    // parse_image (parser)
1090    // ---------------------------------------------------------------------
1091
1092    #[test]
1093    fn parse_image_is_infallible_for_every_adversarial_input() {
1094        // The signature returns Result, but the body swallows the strip_quotes
1095        // error and falls back to the trimmed input — it can never be Err.
1096        let huge = "a".repeat(1_000_000);
1097        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
1098        for input in [
1099            "",
1100            "   ",
1101            "\t\n",
1102            "\0",
1103            "\"",
1104            "'",
1105            "\"mixed'",
1106            "no-quotes",
1107            "url(a.png)",
1108            "9223372036854775807",
1109            "NaN",
1110            "\u{1F600}",
1111            huge.as_str(),
1112            nested.as_str(),
1113        ] {
1114            assert!(parse_image(input).is_ok(), "parse_image({input:?}) must be Ok");
1115        }
1116    }
1117
1118    #[test]
1119    fn parse_image_valid_minimal_positive_control() {
1120        assert_eq!(parse_image("\"image.png\"").unwrap().as_str(), "image.png");
1121        assert_eq!(parse_image("'image.png'").unwrap().as_str(), "image.png");
1122        // Unquoted input is accepted and trimmed.
1123        assert_eq!(parse_image("  image.png  ").unwrap().as_str(), "image.png");
1124        assert_eq!(parse_image("").unwrap().as_str(), "");
1125        assert_eq!(parse_image("   ").unwrap().as_str(), "");
1126    }
1127
1128    #[test]
1129    fn parse_image_quoted_payload_is_not_trimmed() {
1130        // The successful strip_quotes path preserves inner whitespace verbatim,
1131        // while the fallback path trims — the two paths differ.
1132        assert_eq!(parse_image("\"  a  \"").unwrap().as_str(), "  a  ");
1133        assert_eq!(parse_image("  a  ").unwrap().as_str(), "a");
1134    }
1135
1136    #[test]
1137    fn parse_image_malformed_quotes_fall_back_to_the_raw_trimmed_input() {
1138        // Quotes are *retained* when stripping fails — an unbalanced quote is
1139        // silently accepted as part of the path rather than rejected.
1140        assert_eq!(parse_image("\"unclosed").unwrap().as_str(), "\"unclosed");
1141        assert_eq!(parse_image("'unclosed").unwrap().as_str(), "'unclosed");
1142        assert_eq!(parse_image("\"mixed'").unwrap().as_str(), "\"mixed'");
1143        // Padding around the quotes defeats strip_quotes, so the quotes survive.
1144        assert_eq!(parse_image("  \"a\"  ").unwrap().as_str(), "\"a\"");
1145    }
1146
1147    #[test]
1148    fn parse_image_does_not_unwrap_url_functions() {
1149        // parse_image only strips quotes; it is not a url() parser.
1150        assert_eq!(parse_image("url(a.png)").unwrap().as_str(), "url(a.png)");
1151    }
1152
1153    #[test]
1154    fn parse_image_unicode_and_extremely_long_inputs() {
1155        assert_eq!(
1156            parse_image("'\u{1F600}.png'").unwrap().as_str(),
1157            "\u{1F600}.png"
1158        );
1159        let payload = "a".repeat(1_000_000);
1160        let input = format!("\"{payload}\"");
1161        assert_eq!(parse_image(&input).unwrap().as_str().len(), 1_000_000);
1162    }
1163
1164    #[test]
1165    fn parse_image_round_trips_quote_free_payloads() {
1166        for payload in ["a.png", "", "\u{1F600}", "some/deep/path.jpeg", "0"] {
1167            assert_eq!(
1168                parse_image(&format!("\"{payload}\"")).unwrap().as_str(),
1169                payload
1170            );
1171            assert_eq!(
1172                parse_image(&format!("'{payload}'")).unwrap().as_str(),
1173                payload
1174            );
1175        }
1176    }
1177}