Skip to main content

azul_css/props/basic/
parse.rs

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