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

            
5
use 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)"]`
12
3912
pub fn split_string_respect_comma(input: &str) -> Vec<&str> {
13
3912
    split_string_by_char(input, ',')
14
3912
}
15

            
16
/// Splits a string by whitespace, but respects parentheses/braces
17
///
18
/// E.g. `translateX(10px) rotate(90deg)` becomes `["translateX(10px)", "rotate(90deg)"]`
19
134
pub fn split_string_respect_whitespace(input: &str) -> Vec<&str> {
20
134
    let mut items = Vec::<&str>::new();
21
134
    let mut current_start = 0;
22
134
    let mut depth = 0;
23
134
    let input_bytes = input.as_bytes();
24

            
25
2384
    for (idx, &ch) in input_bytes.iter().enumerate() {
26
24
        match ch {
27
150
            b'(' => depth += 1,
28
150
            b')' => depth -= 1,
29
24
            b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
30
16
                if current_start < idx {
31
16
                    items.push(&input[current_start..idx]);
32
16
                }
33
16
                current_start = idx + 1;
34
            }
35
2068
            _ => {}
36
        }
37
    }
38

            
39
    // Add the last segment
40
134
    if current_start < input.len() {
41
134
        items.push(&input[current_start..]);
42
134
    }
43

            
44
134
    items
45
134
}
46

            
47
3912
fn split_string_by_char(input: &str, target_char: char) -> Vec<&str> {
48
3912
    let mut comma_separated_items = Vec::<&str>::new();
49
3912
    let mut current_input = input;
50

            
51
    'outer: loop {
52
4054
        let (skip_next_braces_result, character_was_found) =
53
4054
            match skip_next_braces(current_input, target_char) {
54
4054
                Some(s) => s,
55
                None => break 'outer,
56
            };
57
4054
        if character_was_found {
58
142
            comma_separated_items.push(&current_input[..skip_next_braces_result]);
59
142
            current_input = &current_input[(skip_next_braces_result + 1)..];
60
142
        } else {
61
3912
            comma_separated_items.push(current_input);
62
3912
            break 'outer;
63
        }
64
    }
65

            
66
3912
    comma_separated_items
67
3912
}
68

            
69
/// Given a string, returns how many characters need to be skipped
70
4054
fn skip_next_braces(input: &str, target_char: char) -> Option<(usize, bool)> {
71
4054
    let mut depth = 0;
72
4054
    let mut last_character: Option<usize> = None;
73
4054
    let mut character_was_found = false;
74

            
75
4054
    if input.is_empty() {
76
        return None;
77
4054
    }
78

            
79
20509
    for (idx, ch) in input.char_indices() {
80
20509
        last_character = Some(idx);
81
20509
        match ch {
82
66
            '(' => {
83
66
                depth += 1;
84
66
            }
85
66
            ')' => {
86
66
                depth -= 1;
87
66
            }
88
20377
            c => {
89
20377
                if c == target_char && depth == 0 {
90
142
                    character_was_found = true;
91
142
                    break;
92
20235
                }
93
            }
94
        }
95
    }
96

            
97
4054
    last_character.map(|lc| (lc, character_was_found))
98
4054
}
99

            
100
#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
101
pub enum ParenthesisParseError<'a> {
102
    UnclosedBraces,
103
    NoOpeningBraceFound,
104
    NoClosingBraceFound,
105
    StopWordNotFound(&'a str),
106
    EmptyInput,
107
}
108

            
109
impl_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)]
120
pub enum ParenthesisParseErrorOwned {
121
    UnclosedBraces,
122
    NoOpeningBraceFound,
123
    NoClosingBraceFound,
124
    StopWordNotFound(AzString),
125
    EmptyInput,
126
}
127

            
128
impl<'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

            
146
impl 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
/// ```
190
44911
pub fn parse_parentheses<'a>(
191
44911
    input: &'a str,
192
44911
    stopwords: &[&'static str],
193
44911
) -> Result<(&'static str, &'a str), ParenthesisParseError<'a>> {
194
    use self::ParenthesisParseError::*;
195

            
196
44911
    let input = input.trim();
197
44911
    if input.is_empty() {
198
        return Err(EmptyInput);
199
44911
    }
200

            
201
44911
    let first_open_brace = input.find('(').ok_or(NoOpeningBraceFound)?;
202
649
    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
649
    let mut validated_stopword = None;
206
2071
    for stopword in stopwords {
207
1769
        if found_stopword == *stopword {
208
347
            validated_stopword = Some(stopword);
209
347
            break;
210
1422
        }
211
    }
212

            
213
649
    let validated_stopword = validated_stopword.ok_or(StopWordNotFound(found_stopword))?;
214
347
    let last_closing_brace = input.rfind(')').ok_or(NoClosingBraceFound)?;
215

            
216
339
    Ok((
217
339
        validated_stopword,
218
339
        &input[(first_open_brace + 1)..last_closing_brace],
219
339
    ))
220
44911
}
221

            
222
/// String has unbalanced `'` or `"` quotation marks
223
#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
224
pub struct UnclosedQuotesError<'a>(pub &'a str);
225

            
226
impl<'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)]
234
pub 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
/// ```
253
1404
pub fn strip_quotes<'a>(input: &'a str) -> Result<QuoteStripped<'a>, UnclosedQuotesError<'a>> {
254
1404
    let mut double_quote_iter = input.splitn(2, '"');
255
1404
    double_quote_iter.next();
256
1404
    let mut single_quote_iter = input.splitn(2, '\'');
257
1404
    single_quote_iter.next();
258

            
259
1404
    let first_double_quote = double_quote_iter.next();
260
1404
    let first_single_quote = single_quote_iter.next();
261
1404
    if first_double_quote.is_some() && first_single_quote.is_some() {
262
1
        return Err(UnclosedQuotesError(input));
263
1403
    }
264
1403
    if let Some(quote_contents) = first_double_quote {
265
144
        if !quote_contents.ends_with('"') {
266
            return Err(UnclosedQuotesError(quote_contents));
267
144
        }
268
144
        Ok(QuoteStripped(quote_contents.trim_end_matches("\"")))
269
1259
    } else if let Some(quote_contents) = first_single_quote {
270
3
        if !quote_contents.ends_with('\'') {
271
1
            return Err(UnclosedQuotesError(input));
272
2
        }
273
2
        Ok(QuoteStripped(quote_contents.trim_end_matches("'")))
274
    } else {
275
1256
        Err(UnclosedQuotesError(input))
276
    }
277
1404
}
278

            
279
#[derive(Copy, Clone, PartialEq)]
280
pub enum CssImageParseError<'a> {
281
    UnclosedQuotes(&'a str),
282
}
283

            
284
impl_debug_as_display!(CssImageParseError<'a>);
285
impl_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)]
292
pub enum CssImageParseErrorOwned {
293
    UnclosedQuotes(AzString),
294
}
295

            
296
impl<'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

            
307
impl 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.
320
2
pub fn parse_image<'a>(input: &'a str) -> Result<AzString, CssImageParseError<'a>> {
321
2
    Ok(match strip_quotes(input) {
322
1
        Ok(stripped) => stripped.0.into(),
323
1
        Err(_) => input.trim().into(),
324
    })
325
2
}
326

            
327
#[cfg(all(test, feature = "parser"))]
328
mod tests {
329
    use super::*;
330

            
331
    #[test]
332
1
    fn test_strip_quotes() {
333
1
        assert_eq!(strip_quotes("'hello'").unwrap(), QuoteStripped("hello"));
334
1
        assert_eq!(strip_quotes("\"world\"").unwrap(), QuoteStripped("world"));
335
1
        assert_eq!(
336
1
            strip_quotes("\"  spaced  \"").unwrap(),
337
            QuoteStripped("  spaced  ")
338
        );
339
1
        assert!(strip_quotes("'unclosed").is_err());
340
1
        assert!(strip_quotes("\"mismatched'").is_err());
341
1
        assert!(strip_quotes("no-quotes").is_err());
342
1
    }
343

            
344
    #[test]
345
1
    fn test_parse_parentheses() {
346
1
        assert_eq!(
347
1
            parse_parentheses("url(image.png)", &["url"]),
348
            Ok(("url", "image.png"))
349
        );
350
1
        assert_eq!(
351
1
            parse_parentheses("linear-gradient(red, blue)", &["linear-gradient"]),
352
            Ok(("linear-gradient", "red, blue"))
353
        );
354
1
        assert_eq!(
355
1
            parse_parentheses("var(--my-var, 10px)", &["var"]),
356
            Ok(("var", "--my-var, 10px"))
357
        );
358
1
        assert_eq!(
359
1
            parse_parentheses("  rgb( 255, 0, 0 )  ", &["rgb", "rgba"]),
360
            Ok(("rgb", " 255, 0, 0 "))
361
        );
362
1
    }
363

            
364
    #[test]
365
1
    fn test_parse_parentheses_errors() {
366
        // Stopword not found
367
1
        assert!(parse_parentheses("rgba(255,0,0,1)", &["rgb"]).is_err());
368
        // No opening brace
369
1
        assert!(parse_parentheses("url'image.png'", &["url"]).is_err());
370
        // No closing brace
371
1
        assert!(parse_parentheses("url(image.png", &["url"]).is_err());
372
1
    }
373

            
374
    #[test]
375
1
    fn test_split_string_respect_comma() {
376
        // Simple case
377
1
        let simple = "one, two, three";
378
1
        assert_eq!(
379
1
            split_string_respect_comma(simple),
380
1
            vec!["one", " two", " three"]
381
        );
382

            
383
        // With parentheses
384
1
        let with_parens = "rgba(255, 0, 0, 1), #ff00ff";
385
1
        assert_eq!(
386
1
            split_string_respect_comma(with_parens),
387
1
            vec!["rgba(255, 0, 0, 1)", " #ff00ff"]
388
        );
389

            
390
        // Multiple parentheses
391
1
        let multi_parens =
392
1
            "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1)), url(image.png)";
393
1
        assert_eq!(
394
1
            split_string_respect_comma(multi_parens),
395
1
            vec![
396
                "linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1))",
397
1
                " url(image.png)"
398
            ]
399
        );
400

            
401
        // No commas
402
1
        let no_commas = "rgb(0,0,0)";
403
1
        assert_eq!(split_string_respect_comma(no_commas), vec!["rgb(0,0,0)"]);
404
1
    }
405
}