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]
15pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
16    split_string_by_char(input, ',')
17}
18
19/// Splits a string by whitespace, but respects parentheses/braces
20///
21/// E.g. `translateX(10px) rotate(90deg)` becomes `["translateX(10px)", "rotate(90deg)"]`
22#[must_use]
23pub fn split_string_respect_whitespace(input: &str) -> Vec<&str> {
24    let mut items = Vec::<&str>::new();
25    let mut current_start = 0;
26    let mut depth = 0;
27    let input_bytes = input.as_bytes();
28
29    for (idx, &ch) in input_bytes.iter().enumerate() {
30        match ch {
31            b'(' => depth += 1,
32            b')' => depth -= 1,
33            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
34                if current_start < idx {
35                    items.push(&input[current_start..idx]);
36                }
37                current_start = idx + 1;
38            }
39            _ => {}
40        }
41    }
42
43    // Add the last segment
44    if current_start < input.len() {
45        items.push(&input[current_start..]);
46    }
47
48    items
49}
50
51fn split_string_by_char(input: &str, target_char: char) -> Vec<&str> {
52    let mut comma_separated_items = Vec::<&str>::new();
53    let mut current_input = input;
54
55    'outer: loop {
56        let Some((skip_next_braces_result, character_was_found)) =
57            skip_next_braces(current_input, target_char)
58        else {
59            break 'outer;
60        };
61        if character_was_found {
62            comma_separated_items.push(&current_input[..skip_next_braces_result]);
63            current_input = &current_input[(skip_next_braces_result + 1)..];
64        } else {
65            comma_separated_items.push(current_input);
66            break 'outer;
67        }
68    }
69
70    comma_separated_items
71}
72
73/// Given a string, returns how many characters need to be skipped
74fn skip_next_braces(input: &str, target_char: char) -> Option<(usize, bool)> {
75    let mut depth = 0;
76    let mut last_character: Option<usize> = None;
77    let mut character_was_found = false;
78
79    if input.is_empty() {
80        return None;
81    }
82
83    for (idx, ch) in input.char_indices() {
84        last_character = Some(idx);
85        match ch {
86            '(' => {
87                depth += 1;
88            }
89            ')' => {
90                depth -= 1;
91            }
92            c => {
93                if c == target_char && depth == 0 {
94                    character_was_found = true;
95                    break;
96                }
97            }
98        }
99    }
100
101    last_character.map(|lc| (lc, character_was_found))
102}
103
104#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
105pub enum ParenthesisParseError<'a> {
106    UnclosedBraces,
107    NoOpeningBraceFound,
108    NoClosingBraceFound,
109    StopWordNotFound(&'a str),
110    EmptyInput,
111}
112
113impl_display! { ParenthesisParseError<'a>, {
114    UnclosedBraces => format!("Unclosed parenthesis"),
115    NoOpeningBraceFound => format!("Expected value in parenthesis (missing \"(\")"),
116    NoClosingBraceFound => format!("Missing closing parenthesis (missing \")\")"),
117    StopWordNotFound(e) => format!("Stopword not found, found: \"{}\"", e),
118    EmptyInput => format!("Empty parenthesis"),
119}}
120#[allow(variant_size_differences)]
121// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
122/// Owned version of `ParenthesisParseError`.
123#[derive(Debug, Clone, PartialEq, Eq)]
124#[repr(C, u8)]
125pub enum ParenthesisParseErrorOwned {
126    UnclosedBraces,
127    NoOpeningBraceFound,
128    NoClosingBraceFound,
129    StopWordNotFound(AzString),
130    EmptyInput,
131}
132
133impl ParenthesisParseError<'_> {
134    #[must_use]
135    pub fn to_contained(&self) -> ParenthesisParseErrorOwned {
136        match self {
137            ParenthesisParseError::UnclosedBraces => ParenthesisParseErrorOwned::UnclosedBraces,
138            ParenthesisParseError::NoOpeningBraceFound => {
139                ParenthesisParseErrorOwned::NoOpeningBraceFound
140            }
141            ParenthesisParseError::NoClosingBraceFound => {
142                ParenthesisParseErrorOwned::NoClosingBraceFound
143            }
144            ParenthesisParseError::StopWordNotFound(s) => {
145                ParenthesisParseErrorOwned::StopWordNotFound((*s).to_string().into())
146            }
147            ParenthesisParseError::EmptyInput => ParenthesisParseErrorOwned::EmptyInput,
148        }
149    }
150}
151
152impl ParenthesisParseErrorOwned {
153    #[must_use]
154    pub fn to_shared(&self) -> ParenthesisParseError<'_> {
155        match self {
156            Self::UnclosedBraces => ParenthesisParseError::UnclosedBraces,
157            Self::NoOpeningBraceFound => ParenthesisParseError::NoOpeningBraceFound,
158            Self::NoClosingBraceFound => ParenthesisParseError::NoClosingBraceFound,
159            Self::StopWordNotFound(s) => ParenthesisParseError::StopWordNotFound(s.as_str()),
160            Self::EmptyInput => ParenthesisParseError::EmptyInput,
161        }
162    }
163}
164
165/// Checks whether a given input is enclosed in parentheses, prefixed
166/// by a certain number of stopwords.
167///
168/// On success, returns what the stopword was + the string inside the braces
169/// on failure returns None.
170///
171/// ```rust
172/// # use azul_css::props::basic::parse::{parse_parentheses, ParenthesisParseError::*};
173/// // Search for the nearest "abc()" brace
174/// assert_eq!(
175///     parse_parentheses("abc(def(g))", &["abc"]),
176///     Ok(("abc", "def(g)"))
177/// );
178/// assert_eq!(
179///     parse_parentheses("abc(def(g))", &["def"]),
180///     Err(StopWordNotFound("abc"))
181/// );
182/// assert_eq!(
183///     parse_parentheses("def(ghi(j))", &["def"]),
184///     Ok(("def", "ghi(j)"))
185/// );
186/// assert_eq!(
187///     parse_parentheses("abc(def(g))", &["abc", "def"]),
188///     Ok(("abc", "def(g)"))
189/// );
190/// ```
191/// # Errors
192///
193/// Returns an error if `input` is not a valid CSS `parentheses` value.
194pub fn parse_parentheses<'a>(
195    input: &'a str,
196    stopwords: &[&'static str],
197) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
198    use self::ParenthesisParseError::{
199        EmptyInput, NoClosingBraceFound, NoOpeningBraceFound, StopWordNotFound,
200    };
201
202    let input = input.trim();
203    if input.is_empty() {
204        return Err(EmptyInput);
205    }
206
207    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
208    let found_stopword = &input[..first_open_brace];
209
210    // CSS does not allow for space between the ( and the stopword, so no .trim() here
211    let mut validated_stopword = None;
212    for stopword in stopwords {
213        if found_stopword == *stopword {
214            validated_stopword = Some(stopword);
215            break;
216        }
217    }
218
219    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
220    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
221
222    Ok((
223        validated_stopword,
224        &input[(first_open_brace + 1)..last_closing_brace],
225    ))
226}
227
228/// String has unbalanced `'` or `"` quotation marks
229#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
230pub struct UnclosedQuotesError<'a>(pub &'a str);
231
232impl<'a> From<UnclosedQuotesError<'a>> for CssImageParseError<'a> {
233    fn from(err: UnclosedQuotesError<'a>) -> Self {
234        CssImageParseError::UnclosedQuotes(err.0)
235    }
236}
237
238/// A string that has been stripped of the beginning and ending quote
239#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
240pub struct QuoteStripped<'a>(pub &'a str);
241
242/// Strip quotes from an input, given that both quotes use either `"` or `'`, but not both.
243///
244/// # Example
245///
246/// ```rust
247/// # extern crate azul_css;
248/// # use azul_css::props::basic::parse::{strip_quotes, QuoteStripped, UnclosedQuotesError};
249/// assert_eq!(
250///     strip_quotes("\"Helvetica\""),
251///     Ok(QuoteStripped("Helvetica"))
252/// );
253/// assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
254/// assert_eq!(
255///     strip_quotes("\"Arial'"),
256///     Err(UnclosedQuotesError("\"Arial'"))
257/// );
258/// ```
259/// # Errors
260///
261/// Returns an error if `input` has an opening quote with no matching closing quote.
262pub fn strip_quotes(input: &str) -> Result<QuoteStripped<'_>, UnclosedQuotesError<'_>> {
263    let mut double_quote_iter = input.splitn(2, '"');
264    double_quote_iter.next();
265    let mut single_quote_iter = input.splitn(2, '\'');
266    single_quote_iter.next();
267
268    let first_double_quote = double_quote_iter.next();
269    let first_single_quote = single_quote_iter.next();
270    if first_double_quote.is_some() && first_single_quote.is_some() {
271        return Err(UnclosedQuotesError(input));
272    }
273    if let Some(quote_contents) = first_double_quote {
274        if !quote_contents.ends_with('"') {
275            return Err(UnclosedQuotesError(quote_contents));
276        }
277        Ok(QuoteStripped(quote_contents.trim_end_matches('"')))
278    } else if let Some(quote_contents) = first_single_quote {
279        if !quote_contents.ends_with('\'') {
280            return Err(UnclosedQuotesError(input));
281        }
282        Ok(QuoteStripped(quote_contents.trim_end_matches('\'')))
283    } else {
284        Err(UnclosedQuotesError(input))
285    }
286}
287
288#[derive(Copy, Clone, PartialEq, Eq)]
289pub enum CssImageParseError<'a> {
290    UnclosedQuotes(&'a str),
291}
292
293impl_debug_as_display!(CssImageParseError<'a>);
294impl_display! {CssImageParseError<'a>, {
295    UnclosedQuotes(e) => format!("Unclosed quotes: \"{}\"", e),
296}}
297
298/// Owned version of `CssImageParseError`.
299#[derive(Debug, Clone, PartialEq, Eq)]
300#[repr(C, u8)]
301pub enum CssImageParseErrorOwned {
302    UnclosedQuotes(AzString),
303}
304
305impl CssImageParseError<'_> {
306    /// Converts to the owned variant.
307    #[must_use]
308    pub fn to_contained(&self) -> CssImageParseErrorOwned {
309        match self {
310            CssImageParseError::UnclosedQuotes(s) => {
311                CssImageParseErrorOwned::UnclosedQuotes((*s).to_string().into())
312            }
313        }
314    }
315}
316
317impl CssImageParseErrorOwned {
318    /// Converts to the borrowed variant.
319    #[must_use]
320    pub fn to_shared(&self) -> CssImageParseError<'_> {
321        match self {
322            Self::UnclosedQuotes(s) => CssImageParseError::UnclosedQuotes(s.as_str()),
323        }
324    }
325}
326
327/// A string slice that has been stripped of its quotes.
328/// In CSS, quotes are optional in `url()` so we accept both quoted and unquoted strings.
329/// # Errors
330///
331/// Returns an error if `input` is not a valid CSS `image` value.
332pub fn parse_image(input: &str) -> Result<AzString, CssImageParseError<'_>> {
333    Ok(strip_quotes(input).map_or_else(|_| input.trim().into(), |stripped| stripped.0.into()))
334}
335
336#[cfg(all(test, feature = "parser"))]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_strip_quotes() {
342        assert_eq!(strip_quotes("'hello'").unwrap(), QuoteStripped("hello"));
343        assert_eq!(strip_quotes("\"world\"").unwrap(), QuoteStripped("world"));
344        assert_eq!(
345            strip_quotes("\"  spaced  \"").unwrap(),
346            QuoteStripped("  spaced  ")
347        );
348        assert!(strip_quotes("'unclosed").is_err());
349        assert!(strip_quotes("\"mismatched'").is_err());
350        assert!(strip_quotes("no-quotes").is_err());
351    }
352
353    #[test]
354    fn test_parse_parentheses() {
355        assert_eq!(
356            parse_parentheses("url(image.png)", &["url"]),
357            Ok(("url", "image.png"))
358        );
359        assert_eq!(
360            parse_parentheses("linear-gradient(red, blue)", &["linear-gradient"]),
361            Ok(("linear-gradient", "red, blue"))
362        );
363        assert_eq!(
364            parse_parentheses("var(--my-var, 10px)", &["var"]),
365            Ok(("var", "--my-var, 10px"))
366        );
367        assert_eq!(
368            parse_parentheses("  rgb( 255, 0, 0 )  ", &["rgb", "rgba"]),
369            Ok(("rgb", " 255, 0, 0 "))
370        );
371    }
372
373    #[test]
374    fn test_parse_parentheses_errors() {
375        // Stopword not found
376        assert!(parse_parentheses("rgba(255,0,0,1)", &["rgb"]).is_err());
377        // No opening brace
378        assert!(parse_parentheses("url'image.png'", &["url"]).is_err());
379        // No closing brace
380        assert!(parse_parentheses("url(image.png", &["url"]).is_err());
381    }
382
383    #[test]
384    fn test_split_string_respect_comma() {
385        // Simple case
386        let simple = "one, two, three";
387        assert_eq!(
388            split_string_respect_comma(simple),
389            vec!["one", " two", " three"]
390        );
391
392        // With parentheses
393        let with_parens = "rgba(255, 0, 0, 1), #ff00ff";
394        assert_eq!(
395            split_string_respect_comma(with_parens),
396            vec!["rgba(255, 0, 0, 1)", " #ff00ff"]
397        );
398
399        // Multiple parentheses
400        let multi_parens =
401            "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)), url(image.png)";
402        assert_eq!(
403            split_string_respect_comma(multi_parens),
404            vec![
405                "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))",
406                " url(image.png)"
407            ]
408        );
409
410        // No commas
411        let no_commas = "rgb(0,0,0)";
412        assert_eq!(split_string_respect_comma(no_commas), vec!["rgb(0,0,0)"]);
413    }
414}
415
416#[cfg(test)]
417mod autotest_generated {
418    use super::*;
419
420    // ---------------------------------------------------------------------
421    // skip_next_braces (private, parser)
422    // ---------------------------------------------------------------------
423
424    #[test]
425    fn skip_next_braces_empty_input_returns_none() {
426        assert_eq!(skip_next_braces("", ','), None);
427        assert_eq!(skip_next_braces("", '('), None);
428        assert_eq!(skip_next_braces("", '\0'), None);
429    }
430
431    #[test]
432    fn skip_next_braces_not_found_yields_last_char_start_not_len() {
433        // NOTE: the returned index is the byte offset of the *last char*, not the
434        // string length. Callers must not treat it as an exclusive end bound.
435        assert_eq!(skip_next_braces("abc", ','), Some((2, false)));
436        assert_eq!(skip_next_braces("a", ','), Some((0, false)));
437        // 4-byte emoji: index is the char start (0), never a mid-char offset.
438        assert_eq!(skip_next_braces("\u{1F600}", ','), Some((0, false)));
439    }
440
441    #[test]
442    fn skip_next_braces_finds_target_only_at_depth_zero() {
443        assert_eq!(skip_next_braces("a,b", ','), Some((1, true)));
444        // Comma nested inside parens is invisible; falls through to "not found".
445        assert_eq!(skip_next_braces("(a,b)", ','), Some((4, false)));
446        // First depth-0 comma, after the group closes.
447        assert_eq!(skip_next_braces("(a,b),c", ','), Some((5, true)));
448    }
449
450    #[test]
451    fn skip_next_braces_unbalanced_closing_paren_drives_depth_negative() {
452        // A stray ')' makes depth == -1, so no later comma is ever "at depth 0".
453        // Deterministic + no panic, but the separator is silently swallowed.
454        assert_eq!(skip_next_braces(")a,b", ','), Some((3, false)));
455        assert_eq!(skip_next_braces("))))", ','), Some((3, false)));
456    }
457
458    #[test]
459    fn skip_next_braces_paren_as_target_char_can_never_match() {
460        // The '(' / ')' match arms shadow the target-char arm, so asking for a
461        // parenthesis as the separator always reports "not found".
462        assert_eq!(skip_next_braces("a(b", '('), Some((2, false)));
463        assert_eq!(skip_next_braces("a)b", ')'), Some((2, false)));
464    }
465
466    #[test]
467    fn skip_next_braces_whitespace_only() {
468        assert_eq!(skip_next_braces("   ", ','), Some((2, false)));
469        assert_eq!(skip_next_braces("\t\n", ','), Some((1, false)));
470        assert_eq!(skip_next_braces("   ", ' '), Some((0, true)));
471    }
472
473    #[test]
474    fn skip_next_braces_boundary_number_strings() {
475        assert_eq!(skip_next_braces("0", ','), Some((0, false)));
476        assert_eq!(skip_next_braces("-0", ','), Some((1, false)));
477        assert_eq!(
478            skip_next_braces("9223372036854775807", ','),
479            Some((18, false))
480        );
481        assert_eq!(skip_next_braces("NaN,inf", ','), Some((3, true)));
482        assert_eq!(skip_next_braces("1e309,-1e309", ','), Some((5, true)));
483    }
484
485    #[test]
486    fn skip_next_braces_unicode_indices_stay_on_char_boundaries() {
487        // "e" + combining acute (2 bytes) => comma sits at byte 3.
488        assert_eq!(skip_next_braces("e\u{0301},x", ','), Some((3, true)));
489        // 4-byte emoji then comma at byte 4.
490        assert_eq!(skip_next_braces("\u{1F600},x", ','), Some((4, true)));
491        let s = "\u{1F600}\u{0301}\u{4E2D}";
492        let (idx, found) = skip_next_braces(s, ',').expect("non-empty input");
493        assert!(!found);
494        assert!(s.is_char_boundary(idx));
495    }
496
497    #[test]
498    fn skip_next_braces_extremely_long_input_terminates() {
499        let mut input = "a".repeat(1_000_000);
500        input.push(',');
501        assert_eq!(skip_next_braces(&input, ','), Some((1_000_000, true)));
502    }
503
504    #[test]
505    fn skip_next_braces_deeply_nested_does_not_stack_overflow() {
506        let input = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
507        // Iterative, not recursive: 20k parens, no target found.
508        assert_eq!(skip_next_braces(&input, ','), Some((19_999, false)));
509    }
510
511    // ---------------------------------------------------------------------
512    // split_string_by_char (private, other)
513    // ---------------------------------------------------------------------
514
515    #[test]
516    fn split_string_by_char_empty_input_yields_empty_vec() {
517        // NOTE: differs from str::split, which yields [""] for an empty input.
518        assert!(split_string_by_char("", ',').is_empty());
519        assert!(split_string_by_char("", ';').is_empty());
520    }
521
522    #[test]
523    fn split_string_by_char_respects_nesting_for_any_ascii_separator() {
524        assert_eq!(
525            split_string_by_char("a;b(c;d);e", ';'),
526            vec!["a", "b(c;d)", "e"]
527        );
528        assert_eq!(
529            split_string_by_char("a b(c d) e", ' '),
530            vec!["a", "b(c d)", "e"]
531        );
532    }
533
534    #[test]
535    fn split_string_by_char_paren_separator_never_splits() {
536        assert_eq!(split_string_by_char("a(b)c", '('), vec!["a(b)c"]);
537        assert_eq!(split_string_by_char("a(b)c", ')'), vec!["a(b)c"]);
538    }
539
540    // ---------------------------------------------------------------------
541    // split_string_respect_comma (other)
542    // ---------------------------------------------------------------------
543
544    #[test]
545    fn split_comma_empty_and_separator_only_inputs() {
546        assert!(split_string_respect_comma("").is_empty());
547        assert_eq!(split_string_respect_comma(","), vec![""]);
548        assert_eq!(split_string_respect_comma(",,"), vec!["", ""]);
549        assert_eq!(split_string_respect_comma("a,,b"), vec!["a", "", "b"]);
550    }
551
552    #[test]
553    fn split_comma_trailing_separator_drops_the_empty_tail() {
554        // Asymmetric: a leading comma keeps its empty segment, a trailing one
555        // does not. Pinned so a future refactor has to acknowledge the change.
556        assert_eq!(split_string_respect_comma("a,"), vec!["a"]);
557        assert_eq!(split_string_respect_comma(",a"), vec!["", "a"]);
558    }
559
560    #[test]
561    fn split_comma_unbalanced_closing_paren_swallows_separators() {
562        // Stray ')' => depth goes negative => nothing splits. No panic.
563        assert_eq!(split_string_respect_comma("a),b"), vec!["a),b"]);
564        assert_eq!(split_string_respect_comma("a(b,c"), vec!["a(b,c"]);
565    }
566
567    #[test]
568    fn split_comma_respects_balanced_nesting() {
569        assert_eq!(
570            split_string_respect_comma("rgba(1,2,3),url(a,b)"),
571            vec!["rgba(1,2,3)", "url(a,b)"]
572        );
573        assert_eq!(
574            split_string_respect_comma("f(g(h(1,2),3),4),5"),
575            vec!["f(g(h(1,2),3),4)", "5"]
576        );
577    }
578
579    #[test]
580    fn split_comma_unicode_segments_are_valid_utf8() {
581        assert_eq!(
582            split_string_respect_comma("\u{1F600},h\u{E9}llo,\u{FC}"),
583            vec!["\u{1F600}", "h\u{E9}llo", "\u{FC}"]
584        );
585        // Combining marks must not be sliced apart.
586        assert_eq!(
587            split_string_respect_comma("e\u{0301},a\u{0308}"),
588            vec!["e\u{0301}", "a\u{0308}"]
589        );
590    }
591
592    #[test]
593    fn split_comma_garbage_input_never_panics() {
594        for garbage in [
595            "\0",
596            "\u{FFFD}",
597            ";;;",
598            "((((",
599            "))))",
600            "()",
601            ",()",
602            "(),",
603            "\\\"'`",
604            "\u{200B},\u{200B}",
605            "--,--",
606            "\t,\n,\r",
607        ] {
608            let parts = split_string_respect_comma(garbage);
609            // Every returned slice must be a real substring of the input.
610            for p in &parts {
611                assert!(garbage.contains(p));
612            }
613        }
614    }
615
616    #[test]
617    fn split_comma_extremely_long_inputs_do_not_hang() {
618        let no_comma = "a".repeat(1_000_000);
619        assert_eq!(
620            split_string_respect_comma(&no_comma),
621            vec![no_comma.as_str()]
622        );
623
624        let all_commas = ",".repeat(100_000);
625        let parts = split_string_respect_comma(&all_commas);
626        assert_eq!(parts.len(), 100_000);
627        assert!(parts.iter().all(|p| p.is_empty()));
628    }
629
630    #[test]
631    fn split_comma_deeply_nested_does_not_stack_overflow() {
632        let nested = format!("{}1,2{}", "(".repeat(10_000), ")".repeat(10_000));
633        // Every comma is nested => a single segment.
634        assert_eq!(split_string_respect_comma(&nested), vec![nested.as_str()]);
635    }
636
637    #[test]
638    fn split_comma_round_trips_via_join_when_no_trailing_separator() {
639        for input in [
640            "a,b,c",
641            "one, two, three",
642            "rgba(1,2,3),x",
643            "a,,b",
644            ",a",
645            "rgb(0,0,0)",
646        ] {
647            assert_eq!(split_string_respect_comma(input).join(","), input);
648        }
649    }
650
651    // ---------------------------------------------------------------------
652    // split_string_respect_whitespace (other)
653    // ---------------------------------------------------------------------
654
655    #[test]
656    fn split_whitespace_empty_and_blank_inputs_yield_nothing() {
657        assert!(split_string_respect_whitespace("").is_empty());
658        assert!(split_string_respect_whitespace("   ").is_empty());
659        assert!(split_string_respect_whitespace("\t\n\r").is_empty());
660    }
661
662    #[test]
663    fn split_whitespace_valid_minimal_and_run_collapsing() {
664        assert_eq!(
665            split_string_respect_whitespace("translateX(10px) rotate(90deg)"),
666            vec!["translateX(10px)", "rotate(90deg)"]
667        );
668        assert_eq!(
669            split_string_respect_whitespace("  a\t\tb\n"),
670            vec!["a", "b"]
671        );
672    }
673
674    #[test]
675    fn split_whitespace_respects_balanced_nesting() {
676        assert_eq!(
677            split_string_respect_whitespace("translate( 10px , 20px ) scale(2)"),
678            vec!["translate( 10px , 20px )", "scale(2)"]
679        );
680    }
681
682    #[test]
683    fn split_whitespace_unbalanced_closing_paren_disables_splitting() {
684        assert_eq!(split_string_respect_whitespace("a) b"), vec!["a) b"]);
685        assert_eq!(split_string_respect_whitespace("a( b"), vec!["a( b"]);
686    }
687
688    #[test]
689    fn split_whitespace_unicode_is_split_on_ascii_bytes_only() {
690        // Scanning is byte-wise; UTF-8 continuation bytes are >= 0x80 so they can
691        // never be mistaken for a space/paren => slices stay on char boundaries.
692        assert_eq!(
693            split_string_respect_whitespace("h\u{E9}llo w\u{F6}rld \u{1F600}"),
694            vec!["h\u{E9}llo", "w\u{F6}rld", "\u{1F600}"]
695        );
696        // U+00A0 NBSP is *not* an ASCII space => not a separator.
697        assert_eq!(
698            split_string_respect_whitespace("a\u{A0}b"),
699            vec!["a\u{A0}b"]
700        );
701    }
702
703    #[test]
704    fn split_whitespace_garbage_input_never_panics() {
705        for garbage in ["\0", "((((", "))))", ")(", "\u{FFFD} \u{FFFD}", "  ) (  "] {
706            for p in &split_string_respect_whitespace(garbage) {
707                assert!(garbage.contains(p));
708            }
709        }
710    }
711
712    #[test]
713    fn split_whitespace_extremely_long_inputs_do_not_hang() {
714        let blanks = " ".repeat(1_000_000);
715        assert!(split_string_respect_whitespace(&blanks).is_empty());
716
717        let word = "a".repeat(1_000_000);
718        assert_eq!(split_string_respect_whitespace(&word), vec![word.as_str()]);
719    }
720
721    #[test]
722    fn split_whitespace_deeply_nested_does_not_stack_overflow() {
723        let nested = format!("{}a{}", "(".repeat(10_000), ")".repeat(10_000));
724        let input = format!("{nested} z");
725        assert_eq!(
726            split_string_respect_whitespace(&input),
727            vec![nested.as_str(), "z"]
728        );
729    }
730
731    // ---------------------------------------------------------------------
732    // parse_parentheses (parser)
733    // ---------------------------------------------------------------------
734
735    #[test]
736    fn parse_parentheses_empty_and_whitespace_only_input() {
737        assert_eq!(
738            parse_parentheses("", &["url"]),
739            Err(ParenthesisParseError::EmptyInput)
740        );
741        assert_eq!(
742            parse_parentheses("   ", &["url"]),
743            Err(ParenthesisParseError::EmptyInput)
744        );
745        assert_eq!(
746            parse_parentheses("\t\n", &["url"]),
747            Err(ParenthesisParseError::EmptyInput)
748        );
749        // Empty stopword list can never validate.
750        assert_eq!(
751            parse_parentheses("url(a)", &[]),
752            Err(ParenthesisParseError::StopWordNotFound("url"))
753        );
754    }
755
756    #[test]
757    fn parse_parentheses_valid_minimal_positive_control() {
758        assert_eq!(parse_parentheses("a(b)", &["a"]), Ok(("a", "b")));
759        assert_eq!(parse_parentheses("abc()", &["abc"]), Ok(("abc", "")));
760        assert_eq!(
761            parse_parentheses("abc(def(g))", &["abc", "def"]),
762            Ok(("abc", "def(g)"))
763        );
764    }
765
766    #[test]
767    fn parse_parentheses_missing_braces_and_stopword() {
768        assert_eq!(
769            parse_parentheses("abc", &["abc"]),
770            Err(ParenthesisParseError::NoOpeningBraceFound)
771        );
772        assert_eq!(
773            parse_parentheses("url(image.png", &["url"]),
774            Err(ParenthesisParseError::NoClosingBraceFound)
775        );
776        assert_eq!(
777            parse_parentheses("rgba(1,2,3,4)", &["rgb"]),
778            Err(ParenthesisParseError::StopWordNotFound("rgba"))
779        );
780    }
781
782    #[test]
783    fn parse_parentheses_stopword_must_directly_abut_the_brace() {
784        // CSS forbids whitespace between the function name and '('; the inner
785        // whitespace survives the outer trim, so this must not validate.
786        assert_eq!(
787            parse_parentheses("url (x)", &["url"]),
788            Err(ParenthesisParseError::StopWordNotFound("url "))
789        );
790        assert_eq!(
791            parse_parentheses("URL(x)", &["url"]),
792            Err(ParenthesisParseError::StopWordNotFound("URL"))
793        );
794    }
795
796    #[test]
797    fn parse_parentheses_uses_last_closing_brace_and_drops_trailing_junk() {
798        // rfind(')') => everything after the *last* ')' is silently discarded.
799        assert_eq!(parse_parentheses("url(a)b)", &["url"]), Ok(("url", "a)b")));
800        assert_eq!(
801            parse_parentheses("url(a);garbage", &["url"]),
802            Ok(("url", "a"))
803        );
804        // Outer whitespace is trimmed, inner whitespace is preserved verbatim.
805        assert_eq!(
806            parse_parentheses("  rgb( 1 )  ", &["rgb", "rgba"]),
807            Ok(("rgb", " 1 "))
808        );
809    }
810
811    #[test]
812    fn parse_parentheses_boundary_number_strings_pass_through_verbatim() {
813        for n in [
814            "0",
815            "-0",
816            "NaN",
817            "inf",
818            "-inf",
819            "9223372036854775807",
820            "-9223372036854775808",
821            "1e309",
822            "0.0000000000000000000001",
823        ] {
824            let input = format!("translate({n})");
825            assert_eq!(
826                parse_parentheses(&input, &["translate"]),
827                Ok(("translate", n))
828            );
829        }
830        // A bare number has no brace at all.
831        assert_eq!(
832            parse_parentheses("9223372036854775807", &["translate"]),
833            Err(ParenthesisParseError::NoOpeningBraceFound)
834        );
835    }
836
837    #[test]
838    fn parse_parentheses_unicode_stopword_and_payload() {
839        // Braces are ASCII, so the byte offsets always land on char boundaries.
840        assert_eq!(
841            parse_parentheses("url(\u{1F600}.png)", &["url"]),
842            Ok(("url", "\u{1F600}.png"))
843        );
844        assert_eq!(
845            parse_parentheses("\u{FC}(\u{1F600})", &["\u{FC}"]),
846            Ok(("\u{FC}", "\u{1F600}"))
847        );
848        assert_eq!(
849            parse_parentheses("\u{1F600}(x)", &["url"]),
850            Err(ParenthesisParseError::StopWordNotFound("\u{1F600}"))
851        );
852    }
853
854    #[test]
855    fn parse_parentheses_garbage_never_panics() {
856        for garbage in ["(", ")", ")(", "()", "((((", "))))", "\0(\0)", "\u{FFFD}"] {
857            // Either it parses or it errors — it must not panic, and any Ok
858            // payload must be a substring of the (trimmed) input.
859            if let Ok((_, inner)) = parse_parentheses(garbage, &["", "\u{FFFD}"]) {
860                assert!(garbage.contains(inner));
861            }
862        }
863    }
864
865    #[test]
866    fn parse_parentheses_extremely_long_input_does_not_hang() {
867        let payload = "a".repeat(1_000_000);
868        let input = format!("url({payload})");
869        assert_eq!(
870            parse_parentheses(&input, &["url"]),
871            Ok(("url", payload.as_str()))
872        );
873    }
874
875    #[test]
876    fn parse_parentheses_deeply_nested_does_not_stack_overflow() {
877        let inner = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
878        let input = format!("abc({inner})");
879        // find/rfind based, not recursive-descent.
880        assert_eq!(
881            parse_parentheses(&input, &["abc"]),
882            Ok(("abc", inner.as_str()))
883        );
884    }
885
886    // ---------------------------------------------------------------------
887    // ParenthesisParseError <-> Owned (getters / round-trip)
888    // ---------------------------------------------------------------------
889
890    #[test]
891    fn parenthesis_error_to_contained_maps_each_variant() {
892        assert_eq!(
893            ParenthesisParseError::UnclosedBraces.to_contained(),
894            ParenthesisParseErrorOwned::UnclosedBraces
895        );
896        assert_eq!(
897            ParenthesisParseError::NoOpeningBraceFound.to_contained(),
898            ParenthesisParseErrorOwned::NoOpeningBraceFound
899        );
900        assert_eq!(
901            ParenthesisParseError::NoClosingBraceFound.to_contained(),
902            ParenthesisParseErrorOwned::NoClosingBraceFound
903        );
904        assert_eq!(
905            ParenthesisParseError::EmptyInput.to_contained(),
906            ParenthesisParseErrorOwned::EmptyInput
907        );
908        assert_eq!(
909            ParenthesisParseError::StopWordNotFound("abc").to_contained(),
910            ParenthesisParseErrorOwned::StopWordNotFound("abc".into())
911        );
912    }
913
914    #[test]
915    fn parenthesis_error_round_trips_through_owned() {
916        let huge = "x".repeat(100_000);
917        let cases = [
918            ParenthesisParseError::UnclosedBraces,
919            ParenthesisParseError::NoOpeningBraceFound,
920            ParenthesisParseError::NoClosingBraceFound,
921            ParenthesisParseError::EmptyInput,
922            ParenthesisParseError::StopWordNotFound(""),
923            ParenthesisParseError::StopWordNotFound("url"),
924            ParenthesisParseError::StopWordNotFound("\u{1F600}\u{0301}"),
925            ParenthesisParseError::StopWordNotFound("\0"),
926            ParenthesisParseError::StopWordNotFound(huge.as_str()),
927        ];
928        for case in cases {
929            let owned = case.to_contained();
930            assert_eq!(owned.to_shared(), case, "round-trip must be lossless");
931            // to_shared -> to_contained must also be stable.
932            assert_eq!(owned.to_shared().to_contained(), owned);
933        }
934    }
935
936    #[test]
937    fn parenthesis_error_owned_to_shared_borrows_the_payload() {
938        let owned = ParenthesisParseErrorOwned::StopWordNotFound("linear-gradient".into());
939        match owned.to_shared() {
940            ParenthesisParseError::StopWordNotFound(s) => assert_eq!(s, "linear-gradient"),
941            other => panic!("expected StopWordNotFound, got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn parenthesis_error_display_never_panics_on_extreme_payloads() {
947        for e in [
948            ParenthesisParseError::EmptyInput,
949            ParenthesisParseError::StopWordNotFound(""),
950            ParenthesisParseError::StopWordNotFound("\u{1F600}"),
951        ] {
952            assert!(!format!("{e}").is_empty());
953        }
954    }
955
956    // ---------------------------------------------------------------------
957    // strip_quotes (parser)
958    // ---------------------------------------------------------------------
959
960    #[test]
961    fn strip_quotes_valid_minimal_positive_control() {
962        assert_eq!(
963            strip_quotes("\"Helvetica\""),
964            Ok(QuoteStripped("Helvetica"))
965        );
966        assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
967        // Empty quoted string is legal and yields an empty payload.
968        assert_eq!(strip_quotes("\"\""), Ok(QuoteStripped("")));
969        assert_eq!(strip_quotes("''"), Ok(QuoteStripped("")));
970    }
971
972    #[test]
973    fn strip_quotes_empty_blank_and_unquoted_inputs_error() {
974        assert_eq!(strip_quotes(""), Err(UnclosedQuotesError("")));
975        assert_eq!(strip_quotes("   "), Err(UnclosedQuotesError("   ")));
976        assert_eq!(strip_quotes("\t\n"), Err(UnclosedQuotesError("\t\n")));
977        assert_eq!(
978            strip_quotes("no-quotes"),
979            Err(UnclosedQuotesError("no-quotes"))
980        );
981    }
982
983    #[test]
984    fn strip_quotes_mixed_quote_kinds_are_rejected() {
985        assert_eq!(
986            strip_quotes("\"Arial'"),
987            Err(UnclosedQuotesError("\"Arial'"))
988        );
989        // A legitimate apostrophe inside a double-quoted name is *also* rejected.
990        assert_eq!(
991            strip_quotes("\"Bob's Font\""),
992            Err(UnclosedQuotesError("\"Bob's Font\""))
993        );
994    }
995
996    #[test]
997    fn strip_quotes_unclosed_error_payload_is_asymmetric_between_branches() {
998        // The single-quote branch reports the whole input...
999        assert_eq!(
1000            strip_quotes("'unclosed"),
1001            Err(UnclosedQuotesError("'unclosed"))
1002        );
1003        assert_eq!(strip_quotes("'"), Err(UnclosedQuotesError("'")));
1004        // ...but the double-quote branch reports only the text *after* the quote,
1005        // losing the leading '"'. Pinned as-is; the two branches disagree.
1006        assert_eq!(
1007            strip_quotes("\"unclosed"),
1008            Err(UnclosedQuotesError("unclosed"))
1009        );
1010        assert_eq!(strip_quotes("\""), Err(UnclosedQuotesError("")));
1011    }
1012
1013    #[test]
1014    fn strip_quotes_surrounding_whitespace_defeats_stripping() {
1015        // strip_quotes does not trim, so a padded input is "unclosed".
1016        assert_eq!(
1017            strip_quotes(" \"Arial\" "),
1018            Err(UnclosedQuotesError("Arial\" "))
1019        );
1020        assert_eq!(
1021            strip_quotes(" 'Arial' "),
1022            Err(UnclosedQuotesError(" 'Arial' "))
1023        );
1024        // Inner whitespace, however, is preserved exactly.
1025        assert_eq!(
1026            strip_quotes("\"  spaced  \""),
1027            Ok(QuoteStripped("  spaced  "))
1028        );
1029    }
1030
1031    #[test]
1032    fn strip_quotes_trims_the_entire_trailing_quote_run() {
1033        // trim_end_matches strips *all* trailing quotes, not just one.
1034        assert_eq!(strip_quotes("\"\"\""), Ok(QuoteStripped("")));
1035        assert_eq!(strip_quotes("\"ab\"\"\""), Ok(QuoteStripped("ab")));
1036        // An interior quote survives, so the result can still contain a quote.
1037        assert_eq!(strip_quotes("\"a\"b\""), Ok(QuoteStripped("a\"b")));
1038    }
1039
1040    #[test]
1041    fn strip_quotes_unicode_payload() {
1042        assert_eq!(
1043            strip_quotes("\"\u{1F600}\u{E9}\""),
1044            Ok(QuoteStripped("\u{1F600}\u{E9}"))
1045        );
1046        assert_eq!(
1047            strip_quotes("'e\u{0301}\u{4E2D}'"),
1048            Ok(QuoteStripped("e\u{0301}\u{4E2D}"))
1049        );
1050    }
1051
1052    #[test]
1053    fn strip_quotes_boundary_number_strings() {
1054        for n in ["0", "-0", "NaN", "inf", "9223372036854775807", "1e309"] {
1055            assert_eq!(strip_quotes(&format!("\"{n}\"")), Ok(QuoteStripped(n)));
1056        }
1057    }
1058
1059    #[test]
1060    fn strip_quotes_garbage_never_panics() {
1061        for garbage in ["\0", "\\", "`", "\u{FFFD}", "\"\0\"", "((\"))"] {
1062            let _ = strip_quotes(garbage);
1063        }
1064        assert_eq!(strip_quotes("\"\0\""), Ok(QuoteStripped("\0")));
1065    }
1066
1067    #[test]
1068    fn strip_quotes_extremely_long_and_deeply_nested_inputs() {
1069        let payload = "a".repeat(1_000_000);
1070        let input = format!("\"{payload}\"");
1071        assert_eq!(strip_quotes(&input), Ok(QuoteStripped(payload.as_str())));
1072
1073        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
1074        let quoted = format!("'{nested}'");
1075        assert_eq!(strip_quotes(&quoted), Ok(QuoteStripped(nested.as_str())));
1076    }
1077
1078    #[test]
1079    fn strip_quotes_round_trips_quote_free_payloads() {
1080        for payload in [
1081            "Helvetica",
1082            "",
1083            "  spaced  ",
1084            "url(a,b)",
1085            "\u{1F600}",
1086            "0",
1087            "a\nb",
1088        ] {
1089            assert_eq!(
1090                strip_quotes(&format!("\"{payload}\"")),
1091                Ok(QuoteStripped(payload)),
1092                "double-quote round-trip"
1093            );
1094            assert_eq!(
1095                strip_quotes(&format!("'{payload}'")),
1096                Ok(QuoteStripped(payload)),
1097                "single-quote round-trip"
1098            );
1099        }
1100    }
1101
1102    // ---------------------------------------------------------------------
1103    // CssImageParseError <-> Owned (getters / round-trip)
1104    // ---------------------------------------------------------------------
1105
1106    #[test]
1107    fn css_image_error_round_trips_through_owned() {
1108        let huge = "x".repeat(100_000);
1109        for payload in ["", "a.png", "\u{1F600}\u{0301}", "\0", huge.as_str()] {
1110            let shared = CssImageParseError::UnclosedQuotes(payload);
1111            let owned = shared.to_contained();
1112            assert_eq!(
1113                owned,
1114                CssImageParseErrorOwned::UnclosedQuotes(payload.into())
1115            );
1116            assert_eq!(owned.to_shared(), shared, "round-trip must be lossless");
1117            assert_eq!(owned.to_shared().to_contained(), owned);
1118        }
1119    }
1120
1121    #[test]
1122    fn css_image_error_display_includes_the_payload() {
1123        let e = CssImageParseError::UnclosedQuotes("\u{1F600}");
1124        assert!(format!("{e}").contains('\u{1F600}'));
1125        // Debug is routed through Display; it must not panic on an empty payload.
1126        assert!(!format!("{:?}", CssImageParseError::UnclosedQuotes("")).is_empty());
1127    }
1128
1129    #[test]
1130    fn unclosed_quotes_error_converts_into_css_image_error() {
1131        let e: CssImageParseError<'_> = UnclosedQuotesError("bad").into();
1132        assert_eq!(e, CssImageParseError::UnclosedQuotes("bad"));
1133    }
1134
1135    // ---------------------------------------------------------------------
1136    // parse_image (parser)
1137    // ---------------------------------------------------------------------
1138
1139    #[test]
1140    fn parse_image_is_infallible_for_every_adversarial_input() {
1141        // The signature returns Result, but the body swallows the strip_quotes
1142        // error and falls back to the trimmed input — it can never be Err.
1143        let huge = "a".repeat(1_000_000);
1144        let nested = format!("{}x{}", "(".repeat(10_000), ")".repeat(10_000));
1145        for input in [
1146            "",
1147            "   ",
1148            "\t\n",
1149            "\0",
1150            "\"",
1151            "'",
1152            "\"mixed'",
1153            "no-quotes",
1154            "url(a.png)",
1155            "9223372036854775807",
1156            "NaN",
1157            "\u{1F600}",
1158            huge.as_str(),
1159            nested.as_str(),
1160        ] {
1161            assert!(
1162                parse_image(input).is_ok(),
1163                "parse_image({input:?}) must be Ok"
1164            );
1165        }
1166    }
1167
1168    #[test]
1169    fn parse_image_valid_minimal_positive_control() {
1170        assert_eq!(parse_image("\"image.png\"").unwrap().as_str(), "image.png");
1171        assert_eq!(parse_image("'image.png'").unwrap().as_str(), "image.png");
1172        // Unquoted input is accepted and trimmed.
1173        assert_eq!(parse_image("  image.png  ").unwrap().as_str(), "image.png");
1174        assert_eq!(parse_image("").unwrap().as_str(), "");
1175        assert_eq!(parse_image("   ").unwrap().as_str(), "");
1176    }
1177
1178    #[test]
1179    fn parse_image_quoted_payload_is_not_trimmed() {
1180        // The successful strip_quotes path preserves inner whitespace verbatim,
1181        // while the fallback path trims — the two paths differ.
1182        assert_eq!(parse_image("\"  a  \"").unwrap().as_str(), "  a  ");
1183        assert_eq!(parse_image("  a  ").unwrap().as_str(), "a");
1184    }
1185
1186    #[test]
1187    fn parse_image_malformed_quotes_fall_back_to_the_raw_trimmed_input() {
1188        // Quotes are *retained* when stripping fails — an unbalanced quote is
1189        // silently accepted as part of the path rather than rejected.
1190        assert_eq!(parse_image("\"unclosed").unwrap().as_str(), "\"unclosed");
1191        assert_eq!(parse_image("'unclosed").unwrap().as_str(), "'unclosed");
1192        assert_eq!(parse_image("\"mixed'").unwrap().as_str(), "\"mixed'");
1193        // Padding around the quotes defeats strip_quotes, so the quotes survive.
1194        assert_eq!(parse_image("  \"a\"  ").unwrap().as_str(), "\"a\"");
1195    }
1196
1197    #[test]
1198    fn parse_image_does_not_unwrap_url_functions() {
1199        // parse_image only strips quotes; it is not a url() parser.
1200        assert_eq!(parse_image("url(a.png)").unwrap().as_str(), "url(a.png)");
1201    }
1202
1203    #[test]
1204    fn parse_image_unicode_and_extremely_long_inputs() {
1205        assert_eq!(
1206            parse_image("'\u{1F600}.png'").unwrap().as_str(),
1207            "\u{1F600}.png"
1208        );
1209        let payload = "a".repeat(1_000_000);
1210        let input = format!("\"{payload}\"");
1211        assert_eq!(parse_image(&input).unwrap().as_str().len(), 1_000_000);
1212    }
1213
1214    #[test]
1215    fn parse_image_round_trips_quote_free_payloads() {
1216        for payload in ["a.png", "", "\u{1F600}", "some/deep/path.jpeg", "0"] {
1217            assert_eq!(
1218                parse_image(&format!("\"{payload}\"")).unwrap().as_str(),
1219                payload
1220            );
1221            assert_eq!(
1222                parse_image(&format!("'{payload}'")).unwrap().as_str(),
1223                payload
1224            );
1225        }
1226    }
1227}