Skip to main content

azul_css/props/basic/
parse.rs

1//! CSS string parsing utilities: parenthesized expressions, quote stripping,
2//! comma/whitespace-aware splitting that respects nesting depth, and CSS
3//! image/url path parsing.
4
5use crate::corety::AzString;
6
7/// Splits a string by commas, but respects parentheses/braces
8///
9/// E.g. `url(something,else), url(another,thing)` becomes `["url(something,else)",
10/// "url(another,thing)"]` whereas a normal split by comma would yield `["url(something", "else)",
11/// "url(another", "thing)"]`
12pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
13    split_string_by_char(input, ',')
14}
15
16/// Splits a string by whitespace, but respects parentheses/braces
17///
18/// E.g. `translateX(10px) rotate(90deg)` becomes `["translateX(10px)", "rotate(90deg)"]`
19pub fn split_string_respect_whitespace(input: &str) -> Vec<&str> {
20    let mut items = Vec::<&str>::new();
21    let mut current_start = 0;
22    let mut depth = 0;
23    let input_bytes = input.as_bytes();
24
25    for (idx, &ch) in input_bytes.iter().enumerate() {
26        match ch {
27            b'(' => depth += 1,
28            b')' => depth -= 1,
29            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
30                if current_start < idx {
31                    items.push(&input[current_start..idx]);
32                }
33                current_start = idx + 1;
34            }
35            _ => {}
36        }
37    }
38
39    // Add the last segment
40    if current_start < input.len() {
41        items.push(&input[current_start..]);
42    }
43
44    items
45}
46
47fn split_string_by_char(input: &str, target_char: char) -> Vec<&str> {
48    let mut comma_separated_items = Vec::<&str>::new();
49    let mut current_input = input;
50
51    'outer: loop {
52        let (skip_next_braces_result, character_was_found) =
53            match skip_next_braces(current_input, target_char) {
54                Some(s) => s,
55                None => break 'outer,
56            };
57        if character_was_found {
58            comma_separated_items.push(&current_input[..skip_next_braces_result]);
59            current_input = &current_input[(skip_next_braces_result + 1)..];
60        } else {
61            comma_separated_items.push(current_input);
62            break 'outer;
63        }
64    }
65
66    comma_separated_items
67}
68
69/// Given a string, returns how many characters need to be skipped
70fn skip_next_braces(input: &str, target_char: char) -> Option<(usize, bool)> {
71    let mut depth = 0;
72    let mut last_character: Option<usize> = None;
73    let mut character_was_found = false;
74
75    if input.is_empty() {
76        return None;
77    }
78
79    for (idx, ch) in input.char_indices() {
80        last_character = Some(idx);
81        match ch {
82            '(' => {
83                depth += 1;
84            }
85            ')' => {
86                depth -= 1;
87            }
88            c => {
89                if c == target_char && depth == 0 {
90                    character_was_found = true;
91                    break;
92                }
93            }
94        }
95    }
96
97    last_character.map(|lc| (lc, character_was_found))
98}
99
100#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
101pub enum ParenthesisParseError<'a> {
102    UnclosedBraces,
103    NoOpeningBraceFound,
104    NoClosingBraceFound,
105    StopWordNotFound(&'a str),
106    EmptyInput,
107}
108
109impl_display! { ParenthesisParseError<'a>, {
110    UnclosedBraces => format!("Unclosed parenthesis"),
111    NoOpeningBraceFound => format!("Expected value in parenthesis (missing \"(\")"),
112    NoClosingBraceFound => format!("Missing closing parenthesis (missing \")\")"),
113    StopWordNotFound(e) => format!("Stopword not found, found: \"{}\"", e),
114    EmptyInput => format!("Empty parenthesis"),
115}}
116
117/// Owned version of ParenthesisParseError.
118#[derive(Debug, Clone, PartialEq)]
119#[repr(C, u8)]
120pub enum ParenthesisParseErrorOwned {
121    UnclosedBraces,
122    NoOpeningBraceFound,
123    NoClosingBraceFound,
124    StopWordNotFound(AzString),
125    EmptyInput,
126}
127
128impl<'a> ParenthesisParseError<'a> {
129    pub fn to_contained(&self) -> ParenthesisParseErrorOwned {
130        match self {
131            ParenthesisParseError::UnclosedBraces => ParenthesisParseErrorOwned::UnclosedBraces,
132            ParenthesisParseError::NoOpeningBraceFound => {
133                ParenthesisParseErrorOwned::NoOpeningBraceFound
134            }
135            ParenthesisParseError::NoClosingBraceFound => {
136                ParenthesisParseErrorOwned::NoClosingBraceFound
137            }
138            ParenthesisParseError::StopWordNotFound(s) => {
139                ParenthesisParseErrorOwned::StopWordNotFound(s.to_string().into())
140            }
141            ParenthesisParseError::EmptyInput => ParenthesisParseErrorOwned::EmptyInput,
142        }
143    }
144}
145
146impl ParenthesisParseErrorOwned {
147    pub fn to_shared<'a>(&'a self) -> ParenthesisParseError<'a> {
148        match self {
149            ParenthesisParseErrorOwned::UnclosedBraces => ParenthesisParseError::UnclosedBraces,
150            ParenthesisParseErrorOwned::NoOpeningBraceFound => {
151                ParenthesisParseError::NoOpeningBraceFound
152            }
153            ParenthesisParseErrorOwned::NoClosingBraceFound => {
154                ParenthesisParseError::NoClosingBraceFound
155            }
156            ParenthesisParseErrorOwned::StopWordNotFound(s) => {
157                ParenthesisParseError::StopWordNotFound(s.as_str())
158            }
159            ParenthesisParseErrorOwned::EmptyInput => ParenthesisParseError::EmptyInput,
160        }
161    }
162}
163
164/// Checks whether a given input is enclosed in parentheses, prefixed
165/// by a certain number of stopwords.
166///
167/// On success, returns what the stopword was + the string inside the braces
168/// on failure returns None.
169///
170/// ```rust
171/// # use azul_css::props::basic::parse::{parse_parentheses, ParenthesisParseError::*};
172/// // Search for the nearest "abc()" brace
173/// assert_eq!(
174///     parse_parentheses("abc(def(g))", &["abc"]),
175///     Ok(("abc", "def(g)"))
176/// );
177/// assert_eq!(
178///     parse_parentheses("abc(def(g))", &["def"]),
179///     Err(StopWordNotFound("abc"))
180/// );
181/// assert_eq!(
182///     parse_parentheses("def(ghi(j))", &["def"]),
183///     Ok(("def", "ghi(j)"))
184/// );
185/// assert_eq!(
186///     parse_parentheses("abc(def(g))", &["abc", "def"]),
187///     Ok(("abc", "def(g)"))
188/// );
189/// ```
190pub fn parse_parentheses<'a>(
191    input: &'a str,
192    stopwords: &[&'static str],
193) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
194    use self::ParenthesisParseError::*;
195
196    let input = input.trim();
197    if input.is_empty() {
198        return Err(EmptyInput);
199    }
200
201    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
202    let found_stopword = &input[..first_open_brace];
203
204    // CSS does not allow for space between the ( and the stopword, so no .trim() here
205    let mut validated_stopword = None;
206    for stopword in stopwords {
207        if found_stopword == *stopword {
208            validated_stopword = Some(stopword);
209            break;
210        }
211    }
212
213    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
214    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
215
216    Ok((
217        validated_stopword,
218        &input[(first_open_brace + 1)..last_closing_brace],
219    ))
220}
221
222/// String has unbalanced `'` or `"` quotation marks
223#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
224pub struct UnclosedQuotesError<'a>(pub &'a str);
225
226impl<'a> From<UnclosedQuotesError<'a>> for CssImageParseError<'a> {
227    fn from(err: UnclosedQuotesError<'a>) -> Self {
228        CssImageParseError::UnclosedQuotes(err.0)
229    }
230}
231
232/// A string that has been stripped of the beginning and ending quote
233#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
234pub struct QuoteStripped<'a>(pub &'a str);
235
236/// Strip quotes from an input, given that both quotes use either `"` or `'`, but not both.
237///
238/// # Example
239///
240/// ```rust
241/// # extern crate azul_css;
242/// # use azul_css::props::basic::parse::{strip_quotes, QuoteStripped, UnclosedQuotesError};
243/// assert_eq!(
244///     strip_quotes("\"Helvetica\""),
245///     Ok(QuoteStripped("Helvetica"))
246/// );
247/// assert_eq!(strip_quotes("'Arial'"), Ok(QuoteStripped("Arial")));
248/// assert_eq!(
249///     strip_quotes("\"Arial'"),
250///     Err(UnclosedQuotesError("\"Arial'"))
251/// );
252/// ```
253pub fn strip_quotes<'a>(input: &'a str) -> Result<QuoteStripped<'a>, UnclosedQuotesError<'a>> {
254    let mut double_quote_iter = input.splitn(2, '"');
255    double_quote_iter.next();
256    let mut single_quote_iter = input.splitn(2, '\'');
257    single_quote_iter.next();
258
259    let first_double_quote = double_quote_iter.next();
260    let first_single_quote = single_quote_iter.next();
261    if first_double_quote.is_some() && first_single_quote.is_some() {
262        return Err(UnclosedQuotesError(input));
263    }
264    if let Some(quote_contents) = first_double_quote {
265        if !quote_contents.ends_with('"') {
266            return Err(UnclosedQuotesError(quote_contents));
267        }
268        Ok(QuoteStripped(quote_contents.trim_end_matches("\"")))
269    } else if let Some(quote_contents) = first_single_quote {
270        if !quote_contents.ends_with('\'') {
271            return Err(UnclosedQuotesError(input));
272        }
273        Ok(QuoteStripped(quote_contents.trim_end_matches("'")))
274    } else {
275        Err(UnclosedQuotesError(input))
276    }
277}
278
279#[derive(Copy, Clone, PartialEq)]
280pub enum CssImageParseError<'a> {
281    UnclosedQuotes(&'a str),
282}
283
284impl_debug_as_display!(CssImageParseError<'a>);
285impl_display! {CssImageParseError<'a>, {
286    UnclosedQuotes(e) => format!("Unclosed quotes: \"{}\"", e),
287}}
288
289/// Owned version of CssImageParseError.
290#[derive(Debug, Clone, PartialEq)]
291#[repr(C, u8)]
292pub enum CssImageParseErrorOwned {
293    UnclosedQuotes(AzString),
294}
295
296impl<'a> CssImageParseError<'a> {
297    /// Converts to the owned variant.
298    pub fn to_contained(&self) -> CssImageParseErrorOwned {
299        match self {
300            CssImageParseError::UnclosedQuotes(s) => {
301                CssImageParseErrorOwned::UnclosedQuotes(s.to_string().into())
302            }
303        }
304    }
305}
306
307impl CssImageParseErrorOwned {
308    /// Converts to the borrowed variant.
309    pub fn to_shared<'a>(&'a self) -> CssImageParseError<'a> {
310        match self {
311            CssImageParseErrorOwned::UnclosedQuotes(s) => {
312                CssImageParseError::UnclosedQuotes(s.as_str())
313            }
314        }
315    }
316}
317
318/// A string slice that has been stripped of its quotes.
319/// In CSS, quotes are optional in url() so we accept both quoted and unquoted strings.
320pub fn parse_image<'a>(input: &'a str) -> Result<AzString, CssImageParseError<'a>> {
321    Ok(match strip_quotes(input) {
322        Ok(stripped) => stripped.0.into(),
323        Err(_) => input.trim().into(),
324    })
325}
326
327#[cfg(all(test, feature = "parser"))]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_strip_quotes() {
333        assert_eq!(strip_quotes("'hello'").unwrap(), QuoteStripped("hello"));
334        assert_eq!(strip_quotes("\"world\"").unwrap(), QuoteStripped("world"));
335        assert_eq!(
336            strip_quotes("\"  spaced  \"").unwrap(),
337            QuoteStripped("  spaced  ")
338        );
339        assert!(strip_quotes("'unclosed").is_err());
340        assert!(strip_quotes("\"mismatched'").is_err());
341        assert!(strip_quotes("no-quotes").is_err());
342    }
343
344    #[test]
345    fn test_parse_parentheses() {
346        assert_eq!(
347            parse_parentheses("url(image.png)", &["url"]),
348            Ok(("url", "image.png"))
349        );
350        assert_eq!(
351            parse_parentheses("linear-gradient(red, blue)", &["linear-gradient"]),
352            Ok(("linear-gradient", "red, blue"))
353        );
354        assert_eq!(
355            parse_parentheses("var(--my-var, 10px)", &["var"]),
356            Ok(("var", "--my-var, 10px"))
357        );
358        assert_eq!(
359            parse_parentheses("  rgb( 255, 0, 0 )  ", &["rgb", "rgba"]),
360            Ok(("rgb", " 255, 0, 0 "))
361        );
362    }
363
364    #[test]
365    fn test_parse_parentheses_errors() {
366        // Stopword not found
367        assert!(parse_parentheses("rgba(255,0,0,1)", &["rgb"]).is_err());
368        // No opening brace
369        assert!(parse_parentheses("url'image.png'", &["url"]).is_err());
370        // No closing brace
371        assert!(parse_parentheses("url(image.png", &["url"]).is_err());
372    }
373
374    #[test]
375    fn test_split_string_respect_comma() {
376        // Simple case
377        let simple = "one, two, three";
378        assert_eq!(
379            split_string_respect_comma(simple),
380            vec!["one", " two", " three"]
381        );
382
383        // With parentheses
384        let with_parens = "rgba(255, 0, 0, 1), #ff00ff";
385        assert_eq!(
386            split_string_respect_comma(with_parens),
387            vec!["rgba(255, 0, 0, 1)", " #ff00ff"]
388        );
389
390        // Multiple parentheses
391        let multi_parens =
392            "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)), url(image.png)";
393        assert_eq!(
394            split_string_respect_comma(multi_parens),
395            vec![
396                "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))",
397                " url(image.png)"
398            ]
399        );
400
401        // No commas
402        let no_commas = "rgb(0,0,0)";
403        assert_eq!(split_string_respect_comma(no_commas), vec!["rgb(0,0,0)"]);
404    }
405}