encre_css/utils/
shadow.rs

1//! Shadow parsing utility functions.
2use super::value_matchers::{is_matching_color, is_matching_length, is_matching_var};
3
4use std::{borrow::Cow, fmt};
5
6const SHADOW_KEYWORDS: [&str; 5] = ["none", "inherit", "initial", "revert", "unset"];
7
8#[derive(Debug, PartialEq)]
9enum Shadow<'a> {
10    Raw([&'a str; 6]),
11    Keyword(&'a str),
12    Variable(&'a str),
13    Shorthand1 {
14        is_inset: bool,
15        offset_x: &'a str,
16        offset_y: &'a str,
17        color: Cow<'a, str>,
18    },
19    Shorthand2 {
20        is_inset: bool,
21        offset_x: &'a str,
22        offset_y: &'a str,
23        blur_radius: &'a str,
24        color: Cow<'a, str>,
25    },
26    Full {
27        is_inset: bool,
28        offset_x: &'a str,
29        offset_y: &'a str,
30        blur_radius: &'a str,
31        spread_radius: &'a str,
32        color: Cow<'a, str>,
33    },
34}
35
36impl Shadow<'_> {
37    fn new_raw() -> Self {
38        Self::Raw([""; 6])
39    }
40
41    /// Parse a real [`Shadow`] from a [`Shadow::Raw`] variant.
42    fn parse(&self) -> Option<Self> {
43        if let Shadow::Raw(shadow) = self {
44            // Handle inset shadows
45            let (is_inset, shadow) = if shadow[0].is_empty() {
46                (false, &shadow[..])
47            } else if shadow[0] == "inset" {
48                (true, &shadow[1..])
49            } else {
50                (false, &shadow[..])
51            };
52
53            // Check the number of parts
54            if shadow.is_empty()
55                && ((is_inset && shadow.len() > 6) || (!is_inset && shadow.len() > 5))
56            {
57                return None;
58            }
59
60            let len = shadow.iter().position(|p| p.is_empty()).unwrap_or(5);
61            if len == 1 {
62                // Keyword value
63                if SHADOW_KEYWORDS.contains(&shadow[0]) {
64                    Some(Shadow::Keyword(shadow[0]))
65                } else if is_matching_var(shadow[0]) {
66                    Some(Shadow::Variable(shadow[0]))
67                } else {
68                    None
69                }
70            } else if len == 3 {
71                // Shorthand 1: offset-x | offset-y | color
72                if is_matching_length(shadow[0])
73                    && is_matching_length(shadow[1])
74                    && is_matching_color(shadow[2])
75                {
76                    Some(Shadow::Shorthand1 {
77                        is_inset,
78                        offset_x: shadow[0],
79                        offset_y: shadow[1],
80                        color: Cow::Borrowed(shadow[2]),
81                    })
82                } else {
83                    None
84                }
85            } else if len == 4 {
86                // Shorthand 2: offset-x | offset-y | blur-radius | color
87                if is_matching_length(shadow[0])
88                    && is_matching_length(shadow[1])
89                    && is_matching_length(shadow[2])
90                    && is_matching_color(shadow[3])
91                {
92                    Some(Shadow::Shorthand2 {
93                        is_inset,
94                        offset_x: shadow[0],
95                        offset_y: shadow[1],
96                        blur_radius: shadow[2],
97                        color: Cow::Borrowed(shadow[3]),
98                    })
99                } else {
100                    None
101                }
102            } else if len == 5 {
103                // Full: offset-x | offset-y | blur-radius | spread-radius | color
104                if is_matching_length(shadow[0])
105                    && is_matching_length(shadow[1])
106                    && is_matching_length(shadow[2])
107                    && is_matching_length(shadow[3])
108                    && is_matching_color(shadow[4])
109                {
110                    Some(Shadow::Full {
111                        is_inset,
112                        offset_x: shadow[0],
113                        offset_y: shadow[1],
114                        blur_radius: shadow[2],
115                        spread_radius: shadow[3],
116                        color: Cow::Borrowed(shadow[4]),
117                    })
118                } else {
119                    None
120                }
121            } else {
122                None
123            }
124        } else {
125            None
126        }
127    }
128}
129
130impl fmt::Display for Shadow<'_> {
131    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
132        match self {
133            Shadow::Raw(s) => write!(f, "{}", s.join(" ")),
134            Shadow::Keyword(keyword) => write!(f, "{keyword}"),
135            Shadow::Variable(variable) => write!(f, "{variable}"),
136            Shadow::Shorthand1 {
137                is_inset,
138                offset_x,
139                offset_y,
140                color,
141            } => write!(
142                f,
143                "{}{offset_x} {offset_y} {color}",
144                if *is_inset { "inset " } else { "" }
145            ),
146            Shadow::Shorthand2 {
147                is_inset,
148                offset_x,
149                offset_y,
150                blur_radius,
151                color,
152            } => write!(
153                f,
154                "{}{offset_x} {offset_y} {blur_radius} {color}",
155                if *is_inset { "inset " } else { "" }
156            ),
157            Shadow::Full {
158                is_inset,
159                offset_x,
160                offset_y,
161                blur_radius,
162                spread_radius,
163                color,
164            } => write!(
165                f,
166                "{}{offset_x} {offset_y} {blur_radius} {spread_radius} {color}",
167                if *is_inset { "inset " } else { "" }
168            ),
169        }
170    }
171}
172
173/// A list of shadows, used in the [`box-shadow`](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) CSS property.
174#[derive(Debug, PartialEq)]
175pub struct ShadowList<'a>(Vec<Shadow<'a>>);
176
177impl<'a> ShadowList<'a> {
178    /// Parse an arbitrary string into a [`ShadowList`].
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// use encre_css::utils::shadow::ShadowList;
184    /// assert_eq!(ShadowList::parse("10px 20px 30px 40px rgb(12 12 12)").unwrap().to_string(), "10px 20px 30px 40px rgb(12 12 12)".to_string());
185    /// assert_eq!(ShadowList::parse("1px 2px 3px 4px"), None);
186    /// ```
187    pub fn parse(value: &'a str) -> Option<Self> {
188        let mut parenthesis_level = 0;
189        let mut last_index = 0;
190        let mut shadows = vec![Shadow::new_raw()];
191
192        for (ch_index, ch) in value.char_indices() {
193            match ch {
194                '(' => {
195                    parenthesis_level += 1;
196                }
197                ')' => {
198                    parenthesis_level -= 1;
199                }
200                ' ' if parenthesis_level == 0 => {
201                    let Shadow::Raw(shadow) = shadows.last_mut()? else {
202                        // Shadow already parsed but a space was encountered
203                        return None;
204                    };
205
206                    if !value[last_index..ch_index].is_empty() {
207                        // Find the index of the first free part
208                        let index = shadow.iter().position(|p| p.is_empty()).unwrap_or(5);
209
210                        // Insert the part
211                        shadow[index] = &value[last_index..ch_index];
212                    }
213
214                    // Update the index (and ignore the space)
215                    last_index = ch_index + 1;
216                }
217                ',' if parenthesis_level == 0 => {
218                    // Add the last part (not suffixed by `_`)
219                    let Shadow::Raw(shadow) = shadows.last_mut()? else {
220                        // Shadow already parsed but a space was encountered
221                        return None;
222                    };
223
224                    // Find the index of the first free part
225                    let index = shadow.iter().position(|p| p.is_empty()).unwrap_or(5);
226
227                    // Insert the part
228                    shadow[index] = &value[last_index..ch_index];
229
230                    // Ignore the shadow if it is empty
231                    if !shadow.iter().all(|p| p.is_empty()) {
232                        // Parse the shadow
233                        let parsed_shadow = shadows.last()?.parse()?;
234                        *shadows.last_mut()? = parsed_shadow;
235
236                        // Start the next shadow
237                        shadows.push(Shadow::new_raw());
238                    }
239
240                    // Update the index (and ignore the comma)
241                    last_index = ch_index + 1;
242                }
243                _ => (),
244            }
245        }
246
247        if value.is_empty() {
248            return None;
249        }
250
251        // Add the last part (not suffixed by `,`)
252        if last_index != value.len() - 1 {
253            // Find the index of the first free part
254            let Shadow::Raw(shadow) = shadows.last_mut()? else {
255                return None;
256            };
257            let index = shadow.iter().position(|p| p.is_empty()).unwrap_or(5);
258
259            // Insert the part
260            shadow[index] = &value[last_index..value.len()];
261
262            // Parse the shadow
263            let parsed_shadow = shadows.last()?.parse()?;
264            *shadows.last_mut()? = parsed_shadow;
265        }
266
267        Some(Self(shadows))
268    }
269
270    /// Replace the color of all shadows with the color given as the first argument.
271    ///
272    /// If the given color contains `{}`, it will be replaced by the old color.
273    pub fn replace_all_colors(&mut self, new_color: &'a str) {
274        self.0.iter_mut().for_each(|shadow| match shadow {
275            Shadow::Shorthand1 { ref mut color, .. }
276            | Shadow::Shorthand2 { ref mut color, .. }
277            | Shadow::Full { ref mut color, .. } => *color = Cow::Owned(new_color.replace("{}", color)),
278            _ => (),
279        });
280    }
281}
282
283impl fmt::Display for ShadowList<'_> {
284    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
285        for (i, v) in self.0.iter().enumerate() {
286            write!(f, "{}{}", v, if i == self.0.len() - 1 { "" } else { "," })?;
287        }
288
289        Ok(())
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn parse_shadow_test() {
299        let shadow = "20px 35px 60px -15px rgba(0,0,0,0.3),0 72px rgba(0,2,42,0.2),inset 23px 42em 42px rgba(255,0,0,1)";
300        let result = ShadowList::parse(shadow).unwrap();
301        assert_eq!(
302            result,
303            ShadowList(vec![
304                Shadow::Full {
305                    is_inset: false,
306                    offset_x: "20px",
307                    offset_y: "35px",
308                    blur_radius: "60px",
309                    spread_radius: "-15px",
310                    color: Cow::Borrowed("rgba(0,0,0,0.3)"),
311                },
312                Shadow::Shorthand1 {
313                    is_inset: false,
314                    offset_x: "0",
315                    offset_y: "72px",
316                    color: Cow::Borrowed("rgba(0,2,42,0.2)"),
317                },
318                Shadow::Shorthand2 {
319                    is_inset: true,
320                    offset_x: "23px",
321                    offset_y: "42em",
322                    blur_radius: "42px",
323                    color: Cow::Borrowed("rgba(255,0,0,1)"),
324                }
325            ])
326        );
327
328        assert_eq!(
329            ShadowList::parse("var(--a, 0 0 1px rgb(0, 0, 0)),1px 2px 3rem rgb(0, 0, 0)").unwrap(),
330            ShadowList(vec![
331                Shadow::Variable("var(--a, 0 0 1px rgb(0, 0, 0))"),
332                Shadow::Shorthand2 {
333                    is_inset: false,
334                    offset_x: "1px",
335                    offset_y: "2px",
336                    blur_radius: "3rem",
337                    color: Cow::Borrowed("rgb(0, 0, 0)"),
338                },
339            ])
340        );
341
342        assert_eq!(
343            ShadowList::parse("none").unwrap(),
344            ShadowList(vec![Shadow::Keyword("none")])
345        );
346
347        assert_eq!(
348            ShadowList::parse("inset 0 5px 90px 40px rgba(0,0,0,0.2)").unwrap(),
349            ShadowList(vec![Shadow::Full {
350                is_inset: true,
351                offset_x: "0",
352                offset_y: "5px",
353                blur_radius: "90px",
354                spread_radius: "40px",
355                color: Cow::Borrowed("rgba(0,0,0,0.2)")
356            }])
357        );
358    }
359
360    #[test]
361    fn format_shadow() {
362        let shadow = "20px 35px 60px -15px rgba(0,0,0,0.3),0 72px rgba(0,2,42,0.2),inset 23px 42em rgba(255,0,0,1)";
363        let result = ShadowList::parse(shadow).unwrap();
364        assert_eq!(&result.to_string(), shadow);
365    }
366
367    #[test]
368    fn replace_all_colors() {
369        let shadow = "20px 35px 60px -15px rgba(0,0,0,0.3),0 72px rgba(0,2,42,0.2),inset 23px 42em rgba(255,0,0,1)";
370        let mut result = ShadowList::parse(shadow).unwrap();
371        result.replace_all_colors("var(--en-shadow, {})");
372        assert_eq!(&result.to_string(), "20px 35px 60px -15px var(--en-shadow, rgba(0,0,0,0.3)),0 72px var(--en-shadow, rgba(0,2,42,0.2)),inset 23px 42em var(--en-shadow, rgba(255,0,0,1))");
373    }
374
375    #[test]
376    fn empty_shadow_should_not_panic() {
377        let shadow = "";
378        let None = ShadowList::parse(shadow) else {
379            unreachable!("an empty shadow should not be parsed correctly");
380        };
381    }
382}