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}