Skip to main content

stylex_css_parser/properties/
border_radius.rs

1/*!
2CSS Border Radius property parsing.
3
4Handles border-radius property syntax including individual values and shorthand notation.
5Supports both horizontal and vertical radius values with proper fallback logic.
6*/
7
8use crate::{
9  css_types::{LengthPercentage, length_percentage_parser},
10  token_parser::TokenParser,
11  token_types::SimpleToken,
12};
13use std::fmt::{self, Display};
14
15/// Individual border radius value (can have different horizontal and vertical values)
16#[derive(Debug, Clone, PartialEq)]
17pub struct BorderRadiusIndividual {
18  pub horizontal: LengthPercentage,
19  pub vertical: LengthPercentage,
20}
21
22impl BorderRadiusIndividual {
23  /// Create a new BorderRadiusIndividual
24  pub fn new(horizontal: LengthPercentage, vertical: Option<LengthPercentage>) -> Self {
25    Self {
26      horizontal: horizontal.clone(),
27      vertical: vertical.unwrap_or(horizontal),
28    }
29  }
30
31  /// Parser for individual border radius values
32  pub fn parser() -> TokenParser<BorderRadiusIndividual> {
33    let whitespace = TokenParser::<SimpleToken>::token(SimpleToken::Whitespace, Some("Whitespace"));
34
35    // Use the WORKING pattern from BorderRadiusShorthand
36    let first_value = length_percentage_parser();
37    let second_value_optional = whitespace
38      .clone()
39      .flat_map(|_| length_percentage_parser(), Some("second_value"))
40      .optional();
41
42    first_value.flat_map(
43      move |first| {
44        second_value_optional.clone().map(
45          move |second_opt| match second_opt {
46            Some(second) => BorderRadiusIndividual::new(first.clone(), Some(second)),
47            None => BorderRadiusIndividual::new(first.clone(), None),
48          },
49          Some("individual_radius"),
50        )
51      },
52      Some("individual_parser"),
53    )
54  }
55}
56
57impl Display for BorderRadiusIndividual {
58  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59    let horizontal = self.horizontal.to_string();
60    let vertical = self.vertical.to_string();
61
62    if horizontal == vertical {
63      write!(f, "{}", horizontal)
64    } else {
65      write!(f, "{} {}", horizontal, vertical)
66    }
67  }
68}
69
70/// Border radius shorthand property (all four corners)
71#[derive(Debug, Clone, PartialEq)]
72pub struct BorderRadiusShorthand {
73  // Horizontal radii
74  pub horizontal_top_left: LengthPercentage,
75  pub horizontal_top_right: LengthPercentage,
76  pub horizontal_bottom_right: LengthPercentage,
77  pub horizontal_bottom_left: LengthPercentage,
78
79  // Vertical radii
80  pub vertical_top_left: LengthPercentage,
81  pub vertical_top_right: LengthPercentage,
82  pub vertical_bottom_right: LengthPercentage,
83  pub vertical_bottom_left: LengthPercentage,
84}
85
86impl BorderRadiusShorthand {
87  /// Create a new BorderRadiusShorthand with CSS shorthand expansion logic
88  #[allow(clippy::too_many_arguments)]
89  pub fn new(
90    horizontal_top_left: LengthPercentage,
91    horizontal_top_right: Option<LengthPercentage>,
92    horizontal_bottom_right: Option<LengthPercentage>,
93    horizontal_bottom_left: Option<LengthPercentage>,
94    vertical_top_left: Option<LengthPercentage>,
95    vertical_top_right: Option<LengthPercentage>,
96    vertical_bottom_right: Option<LengthPercentage>,
97    vertical_bottom_left: Option<LengthPercentage>,
98  ) -> Self {
99    // CSS shorthand expansion logic
100    let h_top_right = horizontal_top_right
101      .clone()
102      .unwrap_or(horizontal_top_left.clone());
103    let h_bottom_right = horizontal_bottom_right
104      .clone()
105      .unwrap_or(horizontal_top_left.clone());
106    let h_bottom_left = horizontal_bottom_left
107      .clone()
108      .unwrap_or(h_top_right.clone());
109
110    let v_top_left = vertical_top_left
111      .clone()
112      .unwrap_or(horizontal_top_left.clone());
113    let v_top_right = vertical_top_right.clone().unwrap_or(v_top_left.clone());
114    let v_bottom_right = vertical_bottom_right.clone().unwrap_or(v_top_left.clone());
115    let v_bottom_left = vertical_bottom_left.clone().unwrap_or(v_top_right.clone());
116
117    Self {
118      horizontal_top_left,
119      horizontal_top_right: h_top_right,
120      horizontal_bottom_right: h_bottom_right,
121      horizontal_bottom_left: h_bottom_left,
122      vertical_top_left: v_top_left,
123      vertical_top_right: v_top_right,
124      vertical_bottom_right: v_bottom_right,
125      vertical_bottom_left: v_bottom_left,
126    }
127  }
128
129  pub fn parser() -> TokenParser<BorderRadiusShorthand> {
130    // Syntax: horizontal-radii [ / vertical-radii ]?
131
132    let whitespace = TokenParser::<SimpleToken>::token(SimpleToken::Whitespace, Some("Whitespace"));
133    let slash = TokenParser::<SimpleToken>::token(SimpleToken::Delim('/'), Some("Slash"));
134
135    // Helper to parse 1-4 space-separated length/percentage values
136    let space_separated_radii = {
137      let first_value = length_percentage_parser();
138      let remaining_values = TokenParser::<LengthPercentage>::zero_or_more(
139        whitespace
140          .clone()
141          .flat_map(|_| length_percentage_parser(), Some("next_value")),
142      );
143
144      first_value.flat_map(
145        move |first| {
146          let first_clone = first.clone();
147          remaining_values.clone().map(
148            move |rest| {
149              let mut all_values = vec![first_clone.clone()];
150              all_values.extend(rest);
151
152              // Limit to 4 values and apply CSS shorthand expansion
153              let values = if all_values.len() > 4 {
154                all_values[..4].to_vec()
155              } else {
156                all_values
157              };
158
159              // Apply CSS shorthand rules
160              match values.len() {
161                1 => [
162                  values[0].clone(),
163                  values[0].clone(),
164                  values[0].clone(),
165                  values[0].clone(),
166                ],
167                2 => [
168                  values[0].clone(),
169                  values[1].clone(),
170                  values[0].clone(),
171                  values[1].clone(),
172                ],
173                3 => [
174                  values[0].clone(),
175                  values[1].clone(),
176                  values[2].clone(),
177                  values[1].clone(),
178                ],
179                4 => [
180                  values[0].clone(),
181                  values[1].clone(),
182                  values[2].clone(),
183                  values[3].clone(),
184                ],
185                _ => [
186                  values[0].clone(),
187                  values[0].clone(),
188                  values[0].clone(),
189                  values[0].clone(),
190                ],
191              }
192            },
193            Some("expand_radii"),
194          )
195        },
196        Some("space_separated"),
197      )
198    };
199
200    // Parse optional " / vertical-radii" part
201    let slash_vertical = {
202      let whitespace_before_slash = whitespace.clone().optional();
203      let whitespace_after_slash = whitespace.clone().optional();
204      let slash_clone = slash.clone();
205      let radii_clone = space_separated_radii.clone();
206
207      whitespace_before_slash
208        .flat_map(move |_| slash_clone.clone(), Some("slash"))
209        .flat_map(
210          move |_| whitespace_after_slash.clone(),
211          Some("ws_after_slash"),
212        )
213        .flat_map(move |_| radii_clone.clone(), Some("vertical_radii"))
214        .optional()
215    };
216
217    // Main parser: horizontal-radii [/ vertical-radii]?
218    space_separated_radii.clone().flat_map(
219      move |horizontal_radii| {
220        let h_radii = horizontal_radii.clone();
221        slash_vertical.clone().map(
222          move |vertical_opt| {
223            let [h_tl, h_tr, h_br, h_bl] = h_radii.clone();
224
225            match vertical_opt {
226              Some(vertical_radii) => {
227                let [v_tl, v_tr, v_br, v_bl] = vertical_radii;
228                BorderRadiusShorthand::new(
229                  h_tl,
230                  Some(h_tr),
231                  Some(h_br),
232                  Some(h_bl),
233                  Some(v_tl),
234                  Some(v_tr),
235                  Some(v_br),
236                  Some(v_bl),
237                )
238              },
239              None => {
240                // Only horizontal radii provided, vertical defaults to horizontal
241                BorderRadiusShorthand::new(
242                  h_tl,
243                  Some(h_tr),
244                  Some(h_br),
245                  Some(h_bl),
246                  None,
247                  None,
248                  None,
249                  None,
250                )
251              },
252            }
253          },
254          Some("with_vertical"),
255        )
256      },
257      Some("main_parser"),
258    )
259  }
260
261  /// Get the shortest possible string representation
262  fn to_shortest_string(&self) -> String {
263    let h_top_left = self.horizontal_top_left.to_string();
264    let h_top_right = self.horizontal_top_right.to_string();
265    let h_bottom_right = self.horizontal_bottom_right.to_string();
266    let h_bottom_left = self.horizontal_bottom_left.to_string();
267
268    // Determine shortest horizontal representation
269    let horizontal_str = if h_top_left == h_top_right
270      && h_top_right == h_bottom_right
271      && h_bottom_right == h_bottom_left
272    {
273      // All four are the same
274      h_top_left.clone()
275    } else if h_top_left == h_bottom_right && h_top_right == h_bottom_left {
276      // TopLeft === BottomRight && TopRight === BottomLeft
277      format!("{} {}", h_top_left, h_top_right)
278    } else if h_top_right == h_bottom_left {
279      // TopRight === BottomLeft
280      format!("{} {} {}", h_top_left, h_top_right, h_bottom_right)
281    } else {
282      // All four values needed
283      format!(
284        "{} {} {} {}",
285        h_top_left, h_top_right, h_bottom_right, h_bottom_left
286      )
287    };
288
289    let v_top_left = self.vertical_top_left.to_string();
290    let v_top_right = self.vertical_top_right.to_string();
291    let v_bottom_right = self.vertical_bottom_right.to_string();
292    let v_bottom_left = self.vertical_bottom_left.to_string();
293
294    // Determine shortest vertical representation
295    let vertical_str = if v_top_left == v_top_right
296      && v_top_right == v_bottom_right
297      && v_bottom_right == v_bottom_left
298    {
299      // All four are the same
300      v_top_left.clone()
301    } else if v_top_left == v_bottom_right && v_top_right == v_bottom_left {
302      // TopLeft === BottomRight && TopRight === BottomLeft
303      format!("{} {}", v_top_left, v_top_right)
304    } else if v_top_right == v_bottom_left {
305      // TopRight === BottomLeft
306      format!("{} {} {}", v_top_left, v_top_right, v_bottom_right)
307    } else {
308      // All four values needed
309      format!(
310        "{} {} {} {}",
311        v_top_left, v_top_right, v_bottom_right, v_bottom_left
312      )
313    };
314
315    // If horizontal and vertical are the same, just return horizontal
316    if horizontal_str == vertical_str {
317      horizontal_str
318    } else {
319      format!("{} / {}", horizontal_str, vertical_str)
320    }
321  }
322}
323
324impl Display for BorderRadiusShorthand {
325  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326    write!(f, "{}", self.to_shortest_string())
327  }
328}
329
330#[cfg(test)]
331mod tests {
332  use super::*;
333  use crate::css_types::{Length, Percentage};
334
335  #[test]
336  fn test_border_radius_individual_creation() {
337    let length = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
338    let radius = BorderRadiusIndividual::new(length.clone(), None);
339
340    assert_eq!(radius.horizontal, length);
341    assert_eq!(radius.vertical, length);
342  }
343
344  #[test]
345  fn test_border_radius_individual_different_values() {
346    let horizontal = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
347    let vertical = LengthPercentage::Percentage(Percentage::new(50.0));
348    let radius = BorderRadiusIndividual::new(horizontal.clone(), Some(vertical.clone()));
349
350    assert_eq!(radius.horizontal, horizontal);
351    assert_eq!(radius.vertical, vertical);
352  }
353
354  #[test]
355  fn test_border_radius_individual_display() {
356    let length = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
357    let radius = BorderRadiusIndividual::new(length, None);
358    assert_eq!(radius.to_string(), "5px");
359
360    let horizontal = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
361    let vertical = LengthPercentage::Percentage(Percentage::new(20.0));
362    let radius2 = BorderRadiusIndividual::new(horizontal, Some(vertical));
363    assert_eq!(radius2.to_string(), "10px 20%");
364  }
365
366  #[test]
367  fn test_border_radius_shorthand_creation() {
368    let value = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
369    let shorthand =
370      BorderRadiusShorthand::new(value.clone(), None, None, None, None, None, None, None);
371
372    // All corners should be the same
373    assert_eq!(shorthand.horizontal_top_left, value);
374    assert_eq!(shorthand.horizontal_top_right, value);
375    assert_eq!(shorthand.horizontal_bottom_right, value);
376    assert_eq!(shorthand.horizontal_bottom_left, value);
377    assert_eq!(shorthand.vertical_top_left, value);
378    assert_eq!(shorthand.vertical_top_right, value);
379    assert_eq!(shorthand.vertical_bottom_right, value);
380    assert_eq!(shorthand.vertical_bottom_left, value);
381  }
382
383  #[test]
384  fn test_border_radius_shorthand_display_single_value() {
385    let value = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
386    let shorthand = BorderRadiusShorthand::new(value, None, None, None, None, None, None, None);
387
388    assert_eq!(shorthand.to_string(), "5px");
389  }
390
391  #[test]
392  fn test_border_radius_shorthand_css_expansion() {
393    let top_left = LengthPercentage::Length(Length::new(1.0, "px".to_string()));
394    let top_right = LengthPercentage::Length(Length::new(2.0, "px".to_string()));
395
396    let shorthand = BorderRadiusShorthand::new(
397      top_left.clone(),
398      Some(top_right.clone()),
399      None, // Should default to top_left
400      None, // Should default to top_right
401      None,
402      None,
403      None,
404      None,
405    );
406
407    assert_eq!(shorthand.horizontal_top_left, top_left);
408    assert_eq!(shorthand.horizontal_top_right, top_right);
409    assert_eq!(shorthand.horizontal_bottom_right, top_left); // Defaults to top_left
410    assert_eq!(shorthand.horizontal_bottom_left, top_right); // Defaults to top_right
411  }
412
413  #[test]
414  fn test_border_radius_individual_parser_creation() {
415    // Basic test that parser can be created
416    let _parser = BorderRadiusIndividual::parser();
417  }
418
419  #[test]
420  fn test_border_radius_shorthand_parser_creation() {
421    // Basic test that parser can be created
422    let _parser = BorderRadiusShorthand::parser();
423  }
424
425  #[test]
426  fn test_border_radius_equality() {
427    let value1 = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
428    let value2 = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
429    let value3 = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
430
431    let radius1 = BorderRadiusIndividual::new(value1.clone(), None);
432    let radius2 = BorderRadiusIndividual::new(value2, None);
433    let radius3 = BorderRadiusIndividual::new(value3, None);
434
435    assert_eq!(radius1, radius2);
436    assert_ne!(radius1, radius3);
437  }
438
439  #[test]
440  fn test_border_radius_common_css_values() {
441    // Test common border-radius values
442    let small = LengthPercentage::Length(Length::new(3.0, "px".to_string()));
443    let medium = LengthPercentage::Length(Length::new(6.0, "px".to_string()));
444    let large = LengthPercentage::Length(Length::new(12.0, "px".to_string()));
445    let circle = LengthPercentage::Percentage(Percentage::new(50.0));
446
447    let small_radius = BorderRadiusIndividual::new(small, None);
448    assert_eq!(small_radius.to_string(), "3px");
449
450    let medium_radius = BorderRadiusIndividual::new(medium, None);
451    assert_eq!(medium_radius.to_string(), "6px");
452
453    let large_radius = BorderRadiusIndividual::new(large, None);
454    assert_eq!(large_radius.to_string(), "12px");
455
456    let circle_radius = BorderRadiusIndividual::new(circle, None);
457    assert_eq!(circle_radius.to_string(), "50%");
458  }
459
460  #[test]
461  fn test_border_radius_elliptical() {
462    // Test elliptical border radius (different horizontal and vertical)
463    let horizontal = LengthPercentage::Length(Length::new(20.0, "px".to_string()));
464    let vertical = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
465
466    let elliptical = BorderRadiusIndividual::new(horizontal, Some(vertical));
467    assert_eq!(elliptical.to_string(), "20px 10px");
468  }
469
470  #[test]
471  fn test_border_radius_mixed_units() {
472    // Test mixing different units
473    let pixels = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
474    let percentage = LengthPercentage::Percentage(Percentage::new(25.0));
475
476    let mixed = BorderRadiusIndividual::new(pixels, Some(percentage));
477    assert_eq!(mixed.to_string(), "5px 25%");
478  }
479
480  #[test]
481  fn test_border_radius_shorthand_slash_separated() {
482    // Test asymmetric border radius: 10px 20px / 5px 15px
483    let h_tl = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
484    let h_tr = LengthPercentage::Length(Length::new(20.0, "px".to_string()));
485    let v_tl = LengthPercentage::Length(Length::new(5.0, "px".to_string()));
486    let v_tr = LengthPercentage::Length(Length::new(15.0, "px".to_string()));
487
488    let radius = BorderRadiusShorthand::new(
489      h_tl,
490      Some(h_tr),
491      None,
492      None,
493      Some(v_tl),
494      Some(v_tr),
495      None,
496      None,
497    );
498
499    // Should output the slash-separated format when horizontal and vertical differ
500    let result = radius.to_string();
501    assert!(result.contains("/"));
502    assert!(result.contains("10px"));
503    assert!(result.contains("20px"));
504    assert!(result.contains("5px"));
505    assert!(result.contains("15px"));
506  }
507
508  #[test]
509  fn test_border_radius_shorthand_no_slash_when_same() {
510    // Test when horizontal and vertical radii are the same, no slash should appear
511    let value = LengthPercentage::Length(Length::new(10.0, "px".to_string()));
512
513    let radius = BorderRadiusShorthand::new(
514      value.clone(),
515      None,
516      None,
517      None,
518      Some(value.clone()),
519      None,
520      None,
521      None,
522    );
523
524    let result = radius.to_string();
525    assert!(!result.contains("/"));
526    assert_eq!(result, "10px");
527  }
528
529  #[test]
530  fn test_border_radius_parser_creation() {
531    // Test that both parsers can be created without issues
532    let _individual = BorderRadiusIndividual::parser();
533    let _shorthand = BorderRadiusShorthand::parser();
534  }
535}