Skip to main content

azul_css/
shape_parser.rs

1//! CSS Shape parsing for shape-inside, shape-outside, and clip-path
2//!
3//! Supports CSS Shapes Level 1 & 2 syntax:
4//! - `circle(radius at x y)`
5//! - `ellipse(rx ry at x y)`
6//! - `polygon(x1 y1, x2 y2, ...)`
7//! - `inset(top right bottom left [round radius])`
8//! - `path(svg-path-data)`
9
10use crate::shape::{CssShape, ShapePoint};
11
12/// Error type for shape parsing failures
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ShapeParseError {
15    /// Unknown shape function — the string contains the unrecognized function name
16    UnknownFunction(String),
17    /// Missing required parameter — the string names the expected parameter
18    MissingParameter(String),
19    /// Invalid numeric value — the string contains the unparseable token
20    InvalidNumber(String),
21    /// Invalid syntax — the string contains a description of what went wrong
22    InvalidSyntax(String),
23    /// Empty input string was provided
24    EmptyInput,
25}
26
27impl core::fmt::Display for ShapeParseError {
28    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29        match self {
30            Self::UnknownFunction(func) => {
31                write!(f, "Unknown shape function: {func}")
32            }
33            Self::MissingParameter(param) => {
34                write!(f, "Missing required parameter: {param}")
35            }
36            Self::InvalidNumber(num) => {
37                write!(f, "Invalid numeric value: {num}")
38            }
39            Self::InvalidSyntax(msg) => {
40                write!(f, "Invalid syntax: {msg}")
41            }
42            Self::EmptyInput => {
43                write!(f, "Empty input")
44            }
45        }
46    }
47}
48
49/// Parses a CSS shape value
50/// # Errors
51///
52/// Returns an error if `input` is not a valid CSS `shape` value.
53pub fn parse_shape(input: &str) -> Result<CssShape, ShapeParseError> {
54    let input = input.trim();
55
56    if input.is_empty() {
57        return Err(ShapeParseError::EmptyInput);
58    }
59
60    // Extract function name and arguments
61    let (func_name, args) = parse_function(input)?;
62
63    match func_name.as_str() {
64        "circle" => parse_circle(&args),
65        "ellipse" => parse_ellipse(&args),
66        "polygon" => parse_polygon(&args),
67        "inset" => parse_inset(&args),
68        "path" => parse_path(&args),
69        _ => Err(ShapeParseError::UnknownFunction(func_name)),
70    }
71}
72
73/// Extracts function name and arguments from "func(args)"
74fn parse_function(input: &str) -> Result<(String, String), ShapeParseError> {
75    let open_paren = input
76        .find('(')
77        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing opening parenthesis".into()))?;
78
79    let close_paren = input
80        .rfind(')')
81        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing closing parenthesis".into()))?;
82
83    if close_paren <= open_paren {
84        return Err(ShapeParseError::InvalidSyntax("Invalid parentheses".into()));
85    }
86
87    let func_name = input[..open_paren].trim().to_string();
88    let args = input[open_paren + 1..close_paren].trim().to_string();
89
90    Ok((func_name, args))
91}
92
93/// Parses a circle: `circle(radius at x y)` or `circle(radius)`
94///
95/// Examples:
96/// - `circle(50px)` - circle at origin with radius 50px
97/// - `circle(50px at 100px 100px)` - circle at (100, 100) with radius 50px
98/// - `circle(50%)` - circle with radius 50% of container
99fn parse_circle(args: &str) -> Result<CssShape, ShapeParseError> {
100    let parts: Vec<&str> = args.split_whitespace().collect();
101
102    if parts.is_empty() {
103        return Err(ShapeParseError::MissingParameter("radius".into()));
104    }
105
106    let radius = parse_length(parts[0])?;
107
108    let center = if parts.len() >= 4 && parts[1] == "at" {
109        let x = parse_length(parts[2])?;
110        let y = parse_length(parts[3])?;
111        ShapePoint::new(x, y)
112    } else {
113        ShapePoint::zero() // Default to origin
114    };
115
116    Ok(CssShape::circle(center, radius))
117}
118
119/// Parses an ellipse: `ellipse(rx ry at x y)` or `ellipse(rx ry)`
120///
121/// Examples:
122/// - `ellipse(50px 75px)` - ellipse at origin
123/// - `ellipse(50px 75px at 100px 100px)` - ellipse at (100, 100)
124fn parse_ellipse(args: &str) -> Result<CssShape, ShapeParseError> {
125    let parts: Vec<&str> = args.split_whitespace().collect();
126
127    if parts.len() < 2 {
128        return Err(ShapeParseError::MissingParameter(
129            "radius_x and radius_y".into(),
130        ));
131    }
132
133    let radius_x = parse_length(parts[0])?;
134    let radius_y = parse_length(parts[1])?;
135
136    let center = if parts.len() >= 5 && parts[2] == "at" {
137        let x = parse_length(parts[3])?;
138        let y = parse_length(parts[4])?;
139        ShapePoint::new(x, y)
140    } else {
141        ShapePoint::zero()
142    };
143
144    Ok(CssShape::ellipse(center, radius_x, radius_y))
145}
146
147/// Parses a polygon: `polygon([fill-rule,] x1 y1, x2 y2, ...)`
148///
149/// Note: the optional fill-rule (`nonzero` or `evenodd`) is parsed but
150/// currently ignored — the scanline rasterizer always uses even-odd fill.
151///
152/// Examples:
153/// - `polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)` - rectangle
154/// - `polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)` - diamond
155/// - `polygon(nonzero, 0 0, 100 0, 100 100)` - with fill rule
156fn parse_polygon(args: &str) -> Result<CssShape, ShapeParseError> {
157    let args = args.trim();
158
159    // Check for optional fill-rule
160    let point_str = if args.starts_with("nonzero,") || args.starts_with("evenodd,") {
161        // Skip fill-rule for now (not used in line segment computation)
162        let comma = args.find(',').unwrap();
163        &args[comma + 1..]
164    } else {
165        args
166    };
167
168    // Split by comma to get coordinate pairs
169    let pairs: Vec<&str> = point_str.split(',').map(str::trim).collect();
170
171    if pairs.is_empty() {
172        return Err(ShapeParseError::MissingParameter(
173            "at least one point".into(),
174        ));
175    }
176
177    let mut points = Vec::new();
178
179    for pair in pairs {
180        let coords: Vec<&str> = pair.split_whitespace().collect();
181
182        if coords.len() < 2 {
183            return Err(ShapeParseError::InvalidSyntax(format!(
184                "Expected x y pair, got: {pair}"
185            )));
186        }
187
188        let x = parse_length(coords[0])?;
189        let y = parse_length(coords[1])?;
190
191        points.push(ShapePoint::new(x, y));
192    }
193
194    if points.len() < 3 {
195        return Err(ShapeParseError::InvalidSyntax(
196            "Polygon must have at least 3 points".into(),
197        ));
198    }
199
200    Ok(CssShape::polygon(points.into()))
201}
202
203/// Parses an inset: `inset(top right bottom left [round radius])`
204///
205/// Examples:
206/// - `inset(10px)` - all sides 10px
207/// - `inset(10px 20px)` - top/bottom 10px, left/right 20px
208/// - `inset(10px 20px 30px)` - top 10px, left/right 20px, bottom 30px
209/// - `inset(10px 20px 30px 40px)` - individual sides
210/// - `inset(10px round 5px)` - with border radius
211fn parse_inset(args: &str) -> Result<CssShape, ShapeParseError> {
212    let args = args.trim();
213
214    // Check for optional "round" keyword for border radius
215    let (inset_str, border_radius) = if let Some(round_pos) = args.find("round") {
216        let insets = args[..round_pos].trim();
217        let radius_str = args[round_pos + 5..].trim();
218        let radius = parse_length(radius_str)?;
219        (insets, Some(radius))
220    } else {
221        (args, None)
222    };
223
224    let values: Vec<&str> = inset_str.split_whitespace().collect();
225
226    if values.is_empty() {
227        return Err(ShapeParseError::MissingParameter("inset values".into()));
228    }
229
230    // Parse insets using CSS shorthand rules (same as margin/padding)
231    let (top, right, bottom, left) = match values.len() {
232        1 => {
233            let all = parse_length(values[0])?;
234            (all, all, all, all)
235        }
236        2 => {
237            let vertical = parse_length(values[0])?;
238            let horizontal = parse_length(values[1])?;
239            (vertical, horizontal, vertical, horizontal)
240        }
241        3 => {
242            let top = parse_length(values[0])?;
243            let horizontal = parse_length(values[1])?;
244            let bottom = parse_length(values[2])?;
245            (top, horizontal, bottom, horizontal)
246        }
247        4 => {
248            let top = parse_length(values[0])?;
249            let right = parse_length(values[1])?;
250            let bottom = parse_length(values[2])?;
251            let left = parse_length(values[3])?;
252            (top, right, bottom, left)
253        }
254        _ => {
255            return Err(ShapeParseError::InvalidSyntax(
256                "Too many inset values (max 4)".into(),
257            ));
258        }
259    };
260
261    border_radius.map_or_else(
262        || Ok(CssShape::inset(top, right, bottom, left)),
263        |radius| Ok(CssShape::inset_rounded(top, right, bottom, left, radius)),
264    )
265}
266
267/// Parses a path: `path("svg-path-data")`
268///
269/// Example:
270/// - `path("M 0 0 L 100 0 L 100 100 Z")`
271fn parse_path(args: &str) -> Result<CssShape, ShapeParseError> {
272    use crate::corety::AzString;
273
274    let args = args.trim();
275
276    // Path data should be quoted.
277    // The len >= 2 check is load-bearing: a LONE `"` satisfies both starts_with and
278    // ends_with (same byte is first and last), and the slice below then became the
279    // reversed range 1..0, which panics instead of returning this Err.
280    if args.len() < 2 || !args.starts_with('"') || !args.ends_with('"') {
281        return Err(ShapeParseError::InvalidSyntax(
282            "Path data must be quoted".into(),
283        ));
284    }
285
286    let path_data = AzString::from(&args[1..args.len() - 1]);
287
288    Ok(CssShape::Path(crate::shape::ShapePath { data: path_data }))
289}
290
291/// Parses a CSS length value (px, %, em, etc.)
292///
293/// For now, only handles px and % values.
294/// TODO: Handle em, rem, vh, vw, etc. (requires layout context)
295fn parse_length(s: &str) -> Result<f32, ShapeParseError> {
296    let s = s.trim();
297
298    if let Some(num_str) = s.strip_suffix("px") {
299        num_str
300            .parse::<f32>()
301            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
302    } else if let Some(num_str) = s.strip_suffix('%') {
303        let percent = num_str
304            .parse::<f32>()
305            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))?;
306        // TODO: Percentage values need container size to resolve
307        // For now, treat as raw value (will need context later)
308        Ok(percent)
309    } else {
310        // Try to parse as unitless number (treat as px)
311        s.parse::<f32>()
312            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    // Tests assert that parsed values equal the exact source literals.
319    #![allow(clippy::float_cmp)]
320    use super::*;
321    use crate::{
322        corety::OptionF32,
323        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
324    };
325
326    #[test]
327    fn test_parse_circle() {
328        let shape = parse_shape("circle(50px at 100px 100px)").unwrap();
329        match shape {
330            CssShape::Circle(ShapeCircle { center, radius }) => {
331                assert_eq!(radius, 50.0);
332                assert_eq!(center.x, 100.0);
333                assert_eq!(center.y, 100.0);
334            }
335            _ => panic!("Expected Circle"),
336        }
337    }
338
339    #[test]
340    fn test_parse_circle_no_position() {
341        let shape = parse_shape("circle(50px)").unwrap();
342        match shape {
343            CssShape::Circle(ShapeCircle { center, radius }) => {
344                assert_eq!(radius, 50.0);
345                assert_eq!(center.x, 0.0);
346                assert_eq!(center.y, 0.0);
347            }
348            _ => panic!("Expected Circle"),
349        }
350    }
351
352    #[test]
353    fn test_parse_ellipse() {
354        let shape = parse_shape("ellipse(50px 75px at 100px 100px)").unwrap();
355        match shape {
356            CssShape::Ellipse(ShapeEllipse {
357                center,
358                radius_x,
359                radius_y,
360            }) => {
361                assert_eq!(radius_x, 50.0);
362                assert_eq!(radius_y, 75.0);
363                assert_eq!(center.x, 100.0);
364                assert_eq!(center.y, 100.0);
365            }
366            _ => panic!("Expected Ellipse"),
367        }
368    }
369
370    #[test]
371    fn test_parse_polygon_rectangle() {
372        let shape = parse_shape("polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)").unwrap();
373        match shape {
374            CssShape::Polygon(ShapePolygon { points }) => {
375                assert_eq!(points.as_ref().len(), 4);
376                assert_eq!(points.as_ref()[0].x, 0.0);
377                assert_eq!(points.as_ref()[0].y, 0.0);
378                assert_eq!(points.as_ref()[2].x, 100.0);
379                assert_eq!(points.as_ref()[2].y, 100.0);
380            }
381            _ => panic!("Expected Polygon"),
382        }
383    }
384
385    #[test]
386    fn test_parse_polygon_star() {
387        // 5-pointed star
388        let shape = parse_shape(
389            "polygon(50px 0px, 61px 35px, 98px 35px, 68px 57px, 79px 91px, 50px 70px, 21px 91px, \
390             32px 57px, 2px 35px, 39px 35px)",
391        )
392        .unwrap();
393        match shape {
394            CssShape::Polygon(ShapePolygon { points }) => {
395                assert_eq!(points.as_ref().len(), 10); // 5-pointed star has 10 vertices
396            }
397            _ => panic!("Expected Polygon"),
398        }
399    }
400
401    #[test]
402    fn test_parse_inset() {
403        let shape = parse_shape("inset(10px 20px 30px 40px)").unwrap();
404        match shape {
405            CssShape::Inset(ShapeInset {
406                inset_top,
407                inset_right,
408                inset_bottom,
409                inset_left,
410                border_radius,
411            }) => {
412                assert_eq!(inset_top, 10.0);
413                assert_eq!(inset_right, 20.0);
414                assert_eq!(inset_bottom, 30.0);
415                assert_eq!(inset_left, 40.0);
416                assert!(matches!(border_radius, OptionF32::None));
417            }
418            _ => panic!("Expected Inset"),
419        }
420    }
421
422    #[test]
423    fn test_parse_inset_rounded() {
424        let shape = parse_shape("inset(10px round 5px)").unwrap();
425        match shape {
426            CssShape::Inset(ShapeInset {
427                inset_top,
428                inset_right,
429                inset_bottom,
430                inset_left,
431                border_radius,
432            }) => {
433                assert_eq!(inset_top, 10.0);
434                assert_eq!(inset_right, 10.0);
435                assert_eq!(inset_bottom, 10.0);
436                assert_eq!(inset_left, 10.0);
437                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
438            }
439            _ => panic!("Expected Inset"),
440        }
441    }
442
443    #[test]
444    fn test_parse_path() {
445        let shape = parse_shape(r#"path("M 0 0 L 100 0 L 100 100 Z")"#).unwrap();
446        match shape {
447            CssShape::Path(ShapePath { data }) => {
448                assert_eq!(data.as_str(), "M 0 0 L 100 0 L 100 100 Z");
449            }
450            _ => panic!("Expected Path"),
451        }
452    }
453
454    #[test]
455    fn test_invalid_function() {
456        let result = parse_shape("unknown(50px)");
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn test_empty_input() {
462        let result = parse_shape("");
463        assert!(matches!(result, Err(ShapeParseError::EmptyInput)));
464    }
465}
466
467#[cfg(test)]
468#[allow(clippy::float_cmp, clippy::unreadable_literal)]
469mod autotest_generated {
470    //! Adversarial tests for the shape parser.
471    //!
472    //! Tests named `bug_*` assert the *correct* behavior and currently FAIL —
473    //! they document a genuine defect, not a broken test.
474    //!
475    //! Tests named `current_*` pin down behavior that is deliberately lenient
476    //! or spec-divergent today; they exist so a future tightening is a visible,
477    //! intentional change rather than a silent one.
478
479    use std::panic::{catch_unwind, AssertUnwindSafe};
480
481    use super::*;
482    use crate::{
483        corety::OptionF32,
484        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
485    };
486
487    // ---- helpers ----------------------------------------------------------
488
489    fn circle_of(shape: &CssShape) -> ShapeCircle {
490        match shape {
491            CssShape::Circle(c) => *c,
492            other => panic!("expected Circle, got {other:?}"),
493        }
494    }
495
496    fn ellipse_of(shape: &CssShape) -> ShapeEllipse {
497        match shape {
498            CssShape::Ellipse(e) => *e,
499            other => panic!("expected Ellipse, got {other:?}"),
500        }
501    }
502
503    fn inset_of(shape: &CssShape) -> ShapeInset {
504        match shape {
505            CssShape::Inset(i) => *i,
506            other => panic!("expected Inset, got {other:?}"),
507        }
508    }
509
510    fn polygon_points(shape: &CssShape) -> Vec<ShapePoint> {
511        match shape {
512            CssShape::Polygon(ShapePolygon { points }) => points.as_ref().to_vec(),
513            other => panic!("expected Polygon, got {other:?}"),
514        }
515    }
516
517    fn path_data(shape: &CssShape) -> String {
518        match shape {
519            CssShape::Path(ShapePath { data }) => data.as_str().to_string(),
520            other => panic!("expected Path, got {other:?}"),
521        }
522    }
523
524    fn radius_of(input: &str) -> f32 {
525        circle_of(&parse_shape(input).unwrap()).radius
526    }
527
528    // =======================================================================
529    // GENUINE BUGS — these assertions fail today.
530    // =======================================================================
531
532    #[test]
533    fn bug_parse_path_bare_quote_char_panics_on_reversed_slice() {
534        // `path(")` -> parse_function yields args == "\"" (a single byte).
535        // In parse_path, `starts_with('"')` and `ends_with('"')` are BOTH true
536        // for that one character, so the "is quoted" guard passes and the body
537        // evaluates `&args[1..args.len() - 1]` == `&args[1..0]`, which panics:
538        //   "slice index starts at 1 but ends at 0"
539        // Correct behavior: reject it as unquoted/invalid syntax.
540        // Fix: require `args.len() >= 2` alongside the two quote checks.
541        let parsed = catch_unwind(AssertUnwindSafe(|| parse_shape("path(\")")));
542        assert!(
543            parsed.is_ok(),
544            "parse_shape(r#\"path(\")\"#) panicked instead of returning Err: parse_path slices \
545             args[1..len-1] on a 1-char arg"
546        );
547        assert!(matches!(
548            parsed.unwrap(),
549            Err(ShapeParseError::InvalidSyntax(_))
550        ));
551    }
552
553    #[test]
554    fn bug_parse_path_direct_single_quote_arg_panics() {
555        // Same defect reached through the private fn, with the argument already
556        // isolated: a lone `"` is not a quoted string and must be an Err.
557        let parsed = catch_unwind(AssertUnwindSafe(|| parse_path("\"")));
558        assert!(
559            parsed.is_ok(),
560            "parse_path(\"\\\"\") panicked instead of returning Err"
561        );
562        assert!(parsed.unwrap().is_err());
563    }
564
565    // =======================================================================
566    // parse_shape — dispatch, framing, hostile inputs
567    // =======================================================================
568
569    #[test]
570    fn shape_empty_and_whitespace_only_input_is_empty_input_err() {
571        assert!(matches!(parse_shape(""), Err(ShapeParseError::EmptyInput)));
572        assert!(matches!(
573            parse_shape("   "),
574            Err(ShapeParseError::EmptyInput)
575        ));
576        assert!(matches!(
577            parse_shape("\t\n\r  \x0c"),
578            Err(ShapeParseError::EmptyInput)
579        ));
580        // U+00A0 NO-BREAK SPACE has White_Space=yes, so str::trim removes it too.
581        assert!(parse_shape("\u{00a0}\u{2003}").is_err());
582    }
583
584    #[test]
585    fn shape_garbage_input_is_rejected_without_panicking() {
586        for garbage in [
587            "!!!",
588            ";;;;",
589            "\0\0\0",
590            "\u{7}\u{1b}[0m",
591            "circle",
592            "circle 50px",
593            "()",
594            "(",
595            ")",
596            ")(",
597            ")circle(",
598            "((((",
599            "))))",
600            "-",
601            "50px",
602            "{}",
603            "circle{50px}",
604            "circle[50px]",
605            "<circle r=\"50\"/>",
606        ] {
607            let parsed = catch_unwind(AssertUnwindSafe(|| parse_shape(garbage)));
608            assert!(parsed.is_ok(), "parse_shape({garbage:?}) panicked");
609            assert!(
610                parsed.unwrap().is_err(),
611                "parse_shape({garbage:?}) unexpectedly succeeded"
612            );
613        }
614    }
615
616    #[test]
617    fn shape_missing_parens_report_which_one_is_missing() {
618        assert!(matches!(
619            parse_shape("circle 50px"),
620            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("opening")
621        ));
622        assert!(matches!(
623            parse_shape("circle(50px"),
624            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("closing")
625        ));
626        // Closing paren before the opening one must not produce a reversed slice.
627        assert!(matches!(
628            parse_shape(")circle("),
629            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("Invalid parentheses")
630        ));
631    }
632
633    #[test]
634    fn shape_unknown_function_names_are_reported_verbatim() {
635        assert!(matches!(
636            parse_shape("unknown(50px)"),
637            Err(ShapeParseError::UnknownFunction(ref f)) if f == "unknown"
638        ));
639        // Empty function name: "()" is not EmptyInput, it is an unknown "" function.
640        assert!(matches!(
641            parse_shape("()"),
642            Err(ShapeParseError::UnknownFunction(ref f)) if f.is_empty()
643        ));
644    }
645
646    #[test]
647    fn current_shape_function_names_are_case_sensitive() {
648        // NOTE: CSS function names are ASCII case-insensitive per spec, so
649        // `CIRCLE(50px)` / `Circle(50px)` should parse. They do not today.
650        // Pinned so that adding case-folding is a deliberate change.
651        assert!(matches!(
652            parse_shape("CIRCLE(50px)"),
653            Err(ShapeParseError::UnknownFunction(ref f)) if f == "CIRCLE"
654        ));
655        assert!(parse_shape("Inset(10px)").is_err());
656    }
657
658    #[test]
659    fn current_shape_trailing_junk_after_the_last_paren_is_silently_dropped() {
660        // parse_function slices between the FIRST '(' and the LAST ')', so
661        // anything after the closing paren is discarded rather than rejected.
662        // The task spec allows "rejected OR trimmed deterministically" — this is
663        // the deterministic-drop branch. Pinned to catch an accidental change.
664        assert_eq!(radius_of("circle(50px) garbage"), 50.0);
665        assert_eq!(radius_of("circle(50px);garbage"), 50.0);
666        assert_eq!(radius_of("circle(50px)!!!"), 50.0);
667        // ...but leading junk becomes part of the function name and IS rejected.
668        assert!(matches!(
669            parse_shape("junk circle(50px)"),
670            Err(ShapeParseError::UnknownFunction(ref f)) if f == "junk circle"
671        ));
672    }
673
674    #[test]
675    fn shape_surrounding_whitespace_is_trimmed_before_dispatch() {
676        assert_eq!(radius_of("   circle(50px)   "), 50.0);
677        assert_eq!(radius_of("\n\tcircle( 50px )\n"), 50.0);
678    }
679
680    #[test]
681    fn shape_extra_closing_paren_inside_args_is_rejected_not_ignored() {
682        // rfind(')') takes the LAST paren, so the inner one lands in the args.
683        assert!(matches!(
684            parse_shape("circle(50px))"),
685            Err(ShapeParseError::InvalidNumber(ref n)) if n == "50px)"
686        ));
687        assert!(parse_shape("circle((50px)").is_err());
688    }
689
690    #[test]
691    fn shape_unicode_input_does_not_panic_or_split_a_codepoint() {
692        for input in [
693            "\u{1F600}",
694            "circle(\u{1F600})",
695            "\u{1F600}(50px)",
696            "cercle\u{301}(50px)",        // combining acute on the name
697            "circle(50px\u{200b})",       // zero-width space glued to the unit
698            "circle(\u{FF15}\u{FF10}px)", // fullwidth digits
699            "円(50px)",
700            "\u{202e}circle(50px)", // RTL override
701            "polygon(\u{1F4A9} \u{1F4A9}, 0 0, 1 1)",
702            "path(\u{1F600})",
703            "inset(\u{1F600} round \u{1F600})",
704            "ellipse(\u{1F600} \u{1F600})",
705        ] {
706            let parsed = catch_unwind(AssertUnwindSafe(|| parse_shape(input)));
707            assert!(parsed.is_ok(), "parse_shape({input:?}) panicked");
708            assert!(
709                parsed.unwrap().is_err(),
710                "parse_shape({input:?}) unexpectedly succeeded"
711            );
712        }
713    }
714
715    #[test]
716    fn shape_multibyte_name_slices_on_a_char_boundary() {
717        // func_name = input[..open_paren]: the byte index of '(' must never land
718        // mid-codepoint. A 4-byte emoji directly before '(' is the tight case.
719        assert!(matches!(
720            parse_shape("\u{1F600}(50px)"),
721            Err(ShapeParseError::UnknownFunction(ref f)) if f == "\u{1F600}"
722        ));
723    }
724
725    #[test]
726    fn shape_deeply_nested_parens_do_not_stack_overflow() {
727        // The parser is iterative (find/rfind), not recursive — 10k nesting
728        // levels must terminate with a plain Err, not blow the stack.
729        let depth = 10_000;
730        let nested = format!("{}{}", "(".repeat(depth), ")".repeat(depth));
731        assert!(matches!(
732            parse_shape(&nested),
733            Err(ShapeParseError::UnknownFunction(ref f)) if f.is_empty()
734        ));
735
736        let nested_circle = format!("circle{}50px{}", "(".repeat(5_000), ")".repeat(5_000));
737        assert!(matches!(
738            parse_shape(&nested_circle),
739            Err(ShapeParseError::InvalidNumber(_))
740        ));
741    }
742
743    #[test]
744    fn shape_extremely_long_input_does_not_hang() {
745        // 1M bytes with no '(' — must fail fast on the framing check.
746        let long_garbage = "x".repeat(1_000_000);
747        assert!(matches!(
748            parse_shape(&long_garbage),
749            Err(ShapeParseError::InvalidSyntax(_))
750        ));
751
752        // 50k-digit number: f32::from_str must saturate to +inf, not hang/panic.
753        let huge = format!("circle({}px)", "9".repeat(50_000));
754        let radius = radius_of(&huge);
755        assert!(radius.is_infinite() && radius.is_sign_positive());
756    }
757
758    #[test]
759    fn shape_parsing_is_deterministic() {
760        let input = "polygon(0px 0px, 100px 0px, 50px 100px)";
761        assert_eq!(parse_shape(input).unwrap(), parse_shape(input).unwrap());
762        assert_eq!(
763            parse_shape("!!!").unwrap_err(),
764            parse_shape("!!!").unwrap_err()
765        );
766    }
767
768    #[test]
769    fn shape_minimal_valid_input_per_function() {
770        assert_eq!(circle_of(&parse_shape("circle(1)").unwrap()).radius, 1.0);
771        assert_eq!(
772            ellipse_of(&parse_shape("ellipse(1 2)").unwrap()).radius_y,
773            2.0
774        );
775        assert_eq!(
776            polygon_points(&parse_shape("polygon(0 0,1 0,0 1)").unwrap()).len(),
777            3
778        );
779        assert_eq!(inset_of(&parse_shape("inset(0)").unwrap()).inset_top, 0.0);
780        assert_eq!(path_data(&parse_shape("path(\"\")").unwrap()), "");
781    }
782
783    // =======================================================================
784    // parse_function
785    // =======================================================================
786
787    #[test]
788    fn function_empty_and_whitespace_input_is_err() {
789        assert!(parse_function("").is_err());
790        assert!(parse_function("   \t\n").is_err());
791    }
792
793    #[test]
794    fn function_splits_and_trims_name_and_args() {
795        let (name, args) = parse_function("  circle  (  50px  )  ").unwrap();
796        assert_eq!(name, "circle");
797        assert_eq!(args, "50px");
798
799        // Empty function, empty args.
800        let (name, args) = parse_function("()").unwrap();
801        assert!(name.is_empty() && args.is_empty());
802    }
803
804    #[test]
805    fn function_uses_first_open_and_last_close_paren() {
806        let (name, args) = parse_function("a(b(c))").unwrap();
807        assert_eq!(name, "a");
808        assert_eq!(
809            args, "b(c)",
810            "rfind(')') must take the outermost close paren"
811        );
812    }
813
814    #[test]
815    fn function_rejects_reversed_and_missing_parens() {
816        assert!(matches!(
817            parse_function(")("),
818            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("Invalid parentheses")
819        ));
820        assert!(parse_function("no parens here").is_err());
821        assert!(parse_function("circle(").is_err());
822        assert!(parse_function("circle)").is_err());
823    }
824
825    #[test]
826    fn function_handles_pathological_lengths_without_panicking() {
827        let long = "y".repeat(1_000_000);
828        assert!(parse_function(&long).is_err());
829
830        // 1M-char argument body: extraction is a slice, so this must be cheap.
831        let long_args = format!("f({})", "z".repeat(1_000_000));
832        let (name, args) = parse_function(&long_args).unwrap();
833        assert_eq!(name, "f");
834        assert_eq!(args.len(), 1_000_000);
835    }
836
837    #[test]
838    fn function_multibyte_args_round_trip_intact() {
839        let (name, args) = parse_function("f(\u{1F600}\u{0301})").unwrap();
840        assert_eq!(name, "f");
841        assert_eq!(args, "\u{1F600}\u{0301}");
842    }
843
844    // =======================================================================
845    // parse_circle
846    // =======================================================================
847
848    #[test]
849    fn circle_empty_or_whitespace_args_report_the_missing_radius() {
850        assert!(matches!(
851            parse_circle(""),
852            Err(ShapeParseError::MissingParameter(ref p)) if p == "radius"
853        ));
854        assert!(matches!(
855            parse_circle("  \t\n "),
856            Err(ShapeParseError::MissingParameter(_))
857        ));
858    }
859
860    #[test]
861    fn circle_garbage_radius_is_an_invalid_number() {
862        assert!(matches!(
863            parse_circle("abc"),
864            Err(ShapeParseError::InvalidNumber(ref n)) if n == "abc"
865        ));
866        assert!(parse_circle("at 10px 10px").is_err());
867        assert!(parse_circle("50px at 10px abc").is_err());
868    }
869
870    #[test]
871    fn current_circle_ignores_a_malformed_at_clause_instead_of_erroring() {
872        // The `at` branch needs >= 4 parts AND parts[1] == "at" (lowercase); if
873        // either check fails the position is silently dropped and the shape
874        // still parses. CSS would reject these. Pinned as current behavior.
875        let truncated = circle_of(&parse_circle("50px at 100px").unwrap());
876        assert_eq!(truncated.center, ShapePoint::zero());
877
878        let wrong_case = circle_of(&parse_circle("50px AT 100px 100px").unwrap());
879        assert_eq!(wrong_case.center, ShapePoint::zero());
880
881        let bad_keyword = circle_of(&parse_circle("50px on 100px 100px").unwrap());
882        assert_eq!(bad_keyword.center, ShapePoint::zero());
883
884        // Trailing extra parts past the `at x y` triple are dropped too.
885        let extra = circle_of(&parse_circle("50px at 1px 2px 3px 4px").unwrap());
886        assert_eq!(extra.center, ShapePoint::new(1.0, 2.0));
887    }
888
889    #[test]
890    fn current_circle_accepts_a_negative_radius() {
891        // CSS rejects negative radii; the parser passes them straight through.
892        assert_eq!(radius_of("circle(-50px)"), -50.0);
893        // -0.0 must keep its sign bit rather than collapse to +0.0.
894        let neg_zero = radius_of("circle(-0px)");
895        assert!(neg_zero == 0.0 && neg_zero.is_sign_negative());
896    }
897
898    #[test]
899    fn circle_non_finite_and_saturating_radii_do_not_panic() {
900        // f32::from_str accepts NaN/inf spellings, so they survive into the shape.
901        assert!(radius_of("circle(NaN)").is_nan());
902        assert!(radius_of("circle(NaNpx)").is_nan());
903        assert!(radius_of("circle(inf)").is_infinite());
904        assert!(radius_of("circle(infinitypx)").is_infinite());
905        assert!(radius_of("circle(-inf)").is_sign_negative());
906
907        // Overflow saturates to inf, underflow flushes to zero — no panic, no wrap.
908        assert!(radius_of("circle(1e39px)").is_infinite());
909        assert_eq!(radius_of("circle(1e-46px)"), 0.0);
910        assert_eq!(
911            radius_of("circle(9223372036854775807px)"),
912            9223372036854775807.0_f32
913        );
914    }
915
916    #[test]
917    fn circle_percentages_are_currently_kept_as_raw_numbers() {
918        // TODO in the source: percentages need a container size. Until then a
919        // "50%" radius is indistinguishable from "50px".
920        assert_eq!(radius_of("circle(50%)"), 50.0);
921        assert_eq!(radius_of("circle(50%)"), radius_of("circle(50px)"));
922    }
923
924    // =======================================================================
925    // parse_ellipse
926    // =======================================================================
927
928    #[test]
929    fn ellipse_requires_two_radii() {
930        assert!(matches!(
931            parse_ellipse(""),
932            Err(ShapeParseError::MissingParameter(ref p)) if p.contains("radius_x")
933        ));
934        assert!(matches!(
935            parse_ellipse("50px"),
936            Err(ShapeParseError::MissingParameter(_))
937        ));
938        assert!(parse_ellipse("50px abc").is_err());
939    }
940
941    #[test]
942    fn current_ellipse_ignores_a_truncated_at_clause() {
943        // Needs >= 5 parts; "50px 75px at 100px" is 4, so the centre is dropped.
944        let truncated = ellipse_of(&parse_ellipse("50px 75px at 100px").unwrap());
945        assert_eq!(truncated.center, ShapePoint::zero());
946
947        // 5 parts but no `at` keyword: extras are dropped, still Ok.
948        let no_keyword = ellipse_of(&parse_ellipse("1px 2px 3px 4px 5px").unwrap());
949        assert_eq!(no_keyword.center, ShapePoint::zero());
950        assert_eq!((no_keyword.radius_x, no_keyword.radius_y), (1.0, 2.0));
951    }
952
953    #[test]
954    fn ellipse_valid_input_maps_radii_and_centre_in_order() {
955        let e = ellipse_of(&parse_shape("ellipse(50px 75px at 10px 20px)").unwrap());
956        assert_eq!(e.radius_x, 50.0);
957        assert_eq!(e.radius_y, 75.0);
958        assert_eq!(e.center, ShapePoint::new(10.0, 20.0));
959    }
960
961    #[test]
962    fn ellipse_non_finite_radii_do_not_panic() {
963        let e = ellipse_of(&parse_shape("ellipse(NaN inf)").unwrap());
964        assert!(e.radius_x.is_nan());
965        assert!(e.radius_y.is_infinite());
966    }
967
968    // =======================================================================
969    // parse_polygon
970    // =======================================================================
971
972    #[test]
973    fn polygon_empty_and_whitespace_args_are_rejected() {
974        // NB: `"".split(',')` yields one empty element, so `pairs` is never
975        // empty and the MissingParameter branch is unreachable — the error
976        // surfaces as InvalidSyntax from the x/y pair check instead.
977        assert!(matches!(
978            parse_polygon(""),
979            Err(ShapeParseError::InvalidSyntax(_))
980        ));
981        assert!(matches!(
982            parse_polygon("   \t "),
983            Err(ShapeParseError::InvalidSyntax(_))
984        ));
985    }
986
987    #[test]
988    fn polygon_needs_at_least_three_points() {
989        assert!(matches!(
990            parse_polygon("0 0"),
991            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("at least 3 points")
992        ));
993        assert!(matches!(
994            parse_polygon("0 0, 1 1"),
995            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("at least 3 points")
996        ));
997        assert_eq!(
998            polygon_points(&parse_polygon("0 0, 1 1, 2 2").unwrap()).len(),
999            3
1000        );
1001    }
1002
1003    #[test]
1004    fn polygon_malformed_pairs_are_rejected() {
1005        // Lone coordinate, trailing comma, doubled comma, missing y.
1006        assert!(parse_polygon("0 0, 1 1, 2").is_err());
1007        assert!(parse_polygon("0 0, 1 1, 2 2,").is_err());
1008        assert!(parse_polygon("0 0,, 1 1, 2 2").is_err());
1009        assert!(parse_polygon(",0 0, 1 1, 2 2").is_err());
1010        assert!(parse_polygon("0 0, 1 1, abc def").is_err());
1011    }
1012
1013    #[test]
1014    fn polygon_fill_rule_prefix_is_stripped_only_when_comma_attached() {
1015        let nonzero = polygon_points(&parse_polygon("nonzero, 0 0, 1 0, 1 1").unwrap());
1016        assert_eq!(nonzero.len(), 3);
1017        assert_eq!(nonzero[0], ShapePoint::zero());
1018
1019        let evenodd = polygon_points(&parse_polygon("evenodd, 0 0, 1 0, 1 1").unwrap());
1020        assert_eq!(evenodd.len(), 3);
1021
1022        // The prefix check is `starts_with("nonzero,")` — a space before the
1023        // comma defeats it, and the keyword then fails as a coordinate.
1024        assert!(matches!(
1025            parse_polygon("nonzero , 0 0, 1 0, 1 1"),
1026            Err(ShapeParseError::InvalidSyntax(_))
1027        ));
1028        // No comma at all: "nonzero" is read as an x coordinate.
1029        assert!(matches!(
1030            parse_polygon("nonzero 0 0, 1 0, 1 1"),
1031            Err(ShapeParseError::InvalidNumber(ref n)) if n == "nonzero"
1032        ));
1033        // Fill rule with no points after it.
1034        assert!(parse_polygon("nonzero,").is_err());
1035    }
1036
1037    #[test]
1038    fn current_polygon_ignores_extra_coordinates_in_a_pair() {
1039        // Only coords[0] and coords[1] are read; a stray third value is dropped.
1040        let points = polygon_points(&parse_polygon("0 0 999, 1 1 999, 2 2 999").unwrap());
1041        assert_eq!(points.len(), 3);
1042        assert_eq!(points[2], ShapePoint::new(2.0, 2.0));
1043    }
1044
1045    #[test]
1046    fn current_polygon_mixes_units_silently() {
1047        // px, %, and unitless all collapse to the same raw f32 today.
1048        let points = polygon_points(&parse_polygon("0% 0px, 100 0%, 50px 100").unwrap());
1049        assert_eq!(points[1], ShapePoint::new(100.0, 0.0));
1050        assert_eq!(points[2], ShapePoint::new(50.0, 100.0));
1051    }
1052
1053    #[test]
1054    fn polygon_non_finite_coordinates_do_not_panic() {
1055        let points = polygon_points(&parse_polygon("NaN 0, inf 1, -inf 2").unwrap());
1056        assert!(points[0].x.is_nan());
1057        assert!(points[1].x.is_infinite() && points[1].x.is_sign_positive());
1058        assert!(points[2].x.is_infinite() && points[2].x.is_sign_negative());
1059    }
1060
1061    #[test]
1062    fn polygon_with_twenty_thousand_points_does_not_hang() {
1063        let mut args = String::with_capacity(20_000 * 10);
1064        for i in 0..20_000 {
1065            if i > 0 {
1066                args.push(',');
1067            }
1068            args.push_str("1px 2px");
1069        }
1070        let points = polygon_points(&parse_polygon(&args).unwrap());
1071        assert_eq!(points.len(), 20_000);
1072        assert_eq!(points[19_999], ShapePoint::new(1.0, 2.0));
1073    }
1074
1075    // =======================================================================
1076    // parse_inset
1077    // =======================================================================
1078
1079    #[test]
1080    fn inset_empty_args_report_missing_values() {
1081        assert!(matches!(
1082            parse_inset(""),
1083            Err(ShapeParseError::MissingParameter(ref p)) if p.contains("inset values")
1084        ));
1085        assert!(matches!(
1086            parse_inset("   "),
1087            Err(ShapeParseError::MissingParameter(_))
1088        ));
1089    }
1090
1091    #[test]
1092    fn inset_shorthand_expansion_follows_the_margin_rules() {
1093        let one = inset_of(&parse_inset("10px").unwrap());
1094        assert_eq!(
1095            (
1096                one.inset_top,
1097                one.inset_right,
1098                one.inset_bottom,
1099                one.inset_left
1100            ),
1101            (10.0, 10.0, 10.0, 10.0)
1102        );
1103
1104        let two = inset_of(&parse_inset("10px 20px").unwrap());
1105        assert_eq!(
1106            (
1107                two.inset_top,
1108                two.inset_right,
1109                two.inset_bottom,
1110                two.inset_left
1111            ),
1112            (10.0, 20.0, 10.0, 20.0)
1113        );
1114
1115        let three = inset_of(&parse_inset("10px 20px 30px").unwrap());
1116        assert_eq!(
1117            (
1118                three.inset_top,
1119                three.inset_right,
1120                three.inset_bottom,
1121                three.inset_left
1122            ),
1123            (10.0, 20.0, 30.0, 20.0)
1124        );
1125
1126        let four = inset_of(&parse_inset("10px 20px 30px 40px").unwrap());
1127        assert_eq!(
1128            (
1129                four.inset_top,
1130                four.inset_right,
1131                four.inset_bottom,
1132                four.inset_left
1133            ),
1134            (10.0, 20.0, 30.0, 40.0)
1135        );
1136    }
1137
1138    #[test]
1139    fn inset_rejects_more_than_four_values() {
1140        assert!(matches!(
1141            parse_inset("1px 2px 3px 4px 5px"),
1142            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("max 4")
1143        ));
1144        // Long runs must hit the same guard, not allocate their way through.
1145        let many = ["1px"; 1_000].join(" ");
1146        assert!(matches!(
1147            parse_inset(&many),
1148            Err(ShapeParseError::InvalidSyntax(_))
1149        ));
1150    }
1151
1152    #[test]
1153    fn inset_round_keyword_splits_insets_from_the_radius() {
1154        let rounded = inset_of(&parse_inset("10px 20px round 5px").unwrap());
1155        assert_eq!(rounded.inset_top, 10.0);
1156        assert_eq!(rounded.inset_right, 20.0);
1157        assert!(matches!(rounded.border_radius, OptionF32::Some(r) if r == 5.0));
1158
1159        let plain = inset_of(&parse_inset("10px").unwrap());
1160        assert!(matches!(plain.border_radius, OptionF32::None));
1161    }
1162
1163    #[test]
1164    fn inset_malformed_round_clauses_are_rejected() {
1165        // "round" with no radius after it.
1166        assert!(matches!(
1167            parse_inset("10px round"),
1168            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
1169        ));
1170        // "round" with no insets before it: the radius parses, then the value
1171        // list comes up empty.
1172        assert!(matches!(
1173            parse_inset("round 5px"),
1174            Err(ShapeParseError::MissingParameter(_))
1175        ));
1176        // A second "round" lands inside the radius token and fails to parse.
1177        assert!(parse_inset("10px round 5px round 6px").is_err());
1178        // `find("round")` is a substring search, so "roundup" also triggers the
1179        // split — and then fails on the leftover "up".
1180        assert!(matches!(
1181            parse_inset("10px roundup"),
1182            Err(ShapeParseError::InvalidNumber(ref n)) if n == "up"
1183        ));
1184    }
1185
1186    #[test]
1187    fn inset_non_finite_and_negative_values_do_not_panic() {
1188        let nan = inset_of(&parse_inset("NaN").unwrap());
1189        assert!(nan.inset_top.is_nan() && nan.inset_left.is_nan());
1190
1191        let huge = inset_of(&parse_inset("1e39px round 1e39px").unwrap());
1192        assert!(huge.inset_top.is_infinite());
1193        assert!(matches!(huge.border_radius, OptionF32::Some(r) if r.is_infinite()));
1194
1195        // Negative insets/radii are accepted (CSS rejects a negative radius).
1196        let negative = inset_of(&parse_inset("-10px round -5px").unwrap());
1197        assert_eq!(negative.inset_top, -10.0);
1198        assert!(matches!(negative.border_radius, OptionF32::Some(r) if r == -5.0));
1199    }
1200
1201    // =======================================================================
1202    // parse_path
1203    // =======================================================================
1204
1205    #[test]
1206    fn path_requires_double_quotes_on_both_ends() {
1207        for unquoted in [
1208            "", "   ", "M 0 0", "\"M 0 0", "M 0 0\"",
1209            "'M 0 0'", // single quotes are not accepted
1210            "`M 0 0`",
1211        ] {
1212            let parsed = catch_unwind(AssertUnwindSafe(|| parse_path(unquoted)));
1213            assert!(parsed.is_ok(), "parse_path({unquoted:?}) panicked");
1214            assert!(
1215                matches!(parsed.unwrap(), Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("quoted")),
1216                "parse_path({unquoted:?}) should be an unquoted-syntax error"
1217            );
1218        }
1219    }
1220
1221    #[test]
1222    fn path_strips_exactly_one_quote_from_each_end() {
1223        assert_eq!(path_data(&parse_path("\"\"").unwrap()), "");
1224        assert_eq!(path_data(&parse_path("\"M 0 0 Z\"").unwrap()), "M 0 0 Z");
1225        // Inner quotes are preserved verbatim.
1226        assert_eq!(path_data(&parse_path("\"a\"b\"").unwrap()), "a\"b");
1227    }
1228
1229    #[test]
1230    fn current_path_data_is_stored_without_validation() {
1231        // ShapePath's doc says the data is stored but not interpreted, so any
1232        // garbage inside the quotes round-trips untouched.
1233        assert_eq!(
1234            path_data(&parse_shape("path(\"not svg at all\")").unwrap()),
1235            "not svg at all"
1236        );
1237        assert_eq!(
1238            path_data(&parse_shape("path(\"\u{1F600}\")").unwrap()),
1239            "\u{1F600}"
1240        );
1241        assert_eq!(
1242            path_data(&parse_shape("path(\"M 0 0 L NaN inf\")").unwrap()),
1243            "M 0 0 L NaN inf"
1244        );
1245    }
1246
1247    #[test]
1248    fn path_multibyte_content_slices_on_char_boundaries() {
1249        // args[1..len-1] indexes bytes: a 4-byte emoji adjacent to each quote is
1250        // the case that would split a codepoint if the bounds were wrong.
1251        let data = path_data(&parse_path("\"\u{1F600}\u{0301}\u{1F600}\"").unwrap());
1252        assert_eq!(data, "\u{1F600}\u{0301}\u{1F600}");
1253    }
1254
1255    #[test]
1256    fn path_with_a_megabyte_of_data_does_not_hang() {
1257        let inner = "L 1 1 ".repeat(150_000);
1258        let arg = format!("\"{inner}\"");
1259        assert_eq!(path_data(&parse_path(&arg).unwrap()), inner);
1260    }
1261
1262    // =======================================================================
1263    // parse_length
1264    // =======================================================================
1265
1266    #[test]
1267    fn length_empty_and_whitespace_are_invalid_numbers() {
1268        assert!(matches!(
1269            parse_length(""),
1270            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
1271        ));
1272        assert!(matches!(
1273            parse_length("  \t\n "),
1274            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
1275        ));
1276        // Bare units with no number.
1277        assert!(matches!(
1278            parse_length("px"),
1279            Err(ShapeParseError::InvalidNumber(ref n)) if n == "px"
1280        ));
1281        assert!(parse_length("%").is_err());
1282    }
1283
1284    #[test]
1285    fn length_accepts_px_percent_and_unitless() {
1286        assert_eq!(parse_length("50px").unwrap(), 50.0);
1287        assert_eq!(parse_length("50%").unwrap(), 50.0);
1288        assert_eq!(parse_length("50").unwrap(), 50.0);
1289        assert_eq!(parse_length("  50px  ").unwrap(), 50.0);
1290        assert_eq!(parse_length("+5px").unwrap(), 5.0);
1291        assert_eq!(parse_length(".5px").unwrap(), 0.5);
1292        assert_eq!(parse_length("5.px").unwrap(), 5.0);
1293        assert_eq!(parse_length("5e2px").unwrap(), 500.0);
1294    }
1295
1296    #[test]
1297    fn current_length_rejects_uppercase_units_and_other_css_units() {
1298        // CSS units are case-insensitive and em/rem/vh/vw/pt are all legal; the
1299        // source has a TODO for the latter. Both are rejected today.
1300        for unsupported in [
1301            "50PX", "50Px", "1em", "1rem", "1vh", "1vw", "1pt", "1cm", "1fr",
1302        ] {
1303            assert!(
1304                parse_length(unsupported).is_err(),
1305                "parse_length({unsupported:?}) unexpectedly succeeded"
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn length_rejects_non_css_numeric_syntax() {
1312        for bad in [
1313            "1_000px", "0x10px", "1,5px", "50px50px", "50%%", "50%px", "--5px", "5 0px", "50 px",
1314            "1e", "e5", "0b101", "١٠px", // arabic-indic digits
1315        ] {
1316            let parsed = catch_unwind(AssertUnwindSafe(|| parse_length(bad)));
1317            assert!(parsed.is_ok(), "parse_length({bad:?}) panicked");
1318            assert!(
1319                parsed.unwrap().is_err(),
1320                "parse_length({bad:?}) unexpectedly succeeded"
1321            );
1322        }
1323    }
1324
1325    #[test]
1326    fn length_saturates_on_overflow_and_flushes_on_underflow() {
1327        assert!(parse_length("1e39").unwrap().is_infinite());
1328        assert!(parse_length("1e39px").unwrap().is_sign_positive());
1329        assert!(parse_length("-1e39px").unwrap().is_sign_negative());
1330        assert_eq!(parse_length("1e-46px").unwrap(), 0.0);
1331        assert_eq!(parse_length("-1e-46px").unwrap(), -0.0);
1332        // f32 boundary values survive exactly.
1333        assert_eq!(parse_length(&format!("{}px", f32::MAX)).unwrap(), f32::MAX);
1334        assert_eq!(parse_length(&format!("{}px", f32::MIN)).unwrap(), f32::MIN);
1335        assert_eq!(
1336            parse_length(&format!("{}px", f32::MIN_POSITIVE)).unwrap(),
1337            f32::MIN_POSITIVE
1338        );
1339        // A double-precision value beyond f32 range clamps to inf, not to a wrap.
1340        assert!(parse_length(&format!("{}px", f64::MAX))
1341            .unwrap()
1342            .is_infinite());
1343    }
1344
1345    #[test]
1346    fn current_length_accepts_nan_and_infinity_spellings() {
1347        // f32::from_str parses "NaN"/"inf"/"infinity" (case-insensitively), so
1348        // these reach the shape structs as non-finite radii/coordinates. CSS has
1349        // no such tokens — a future tightening should reject them here.
1350        assert!(parse_length("NaN").unwrap().is_nan());
1351        assert!(parse_length("nan").unwrap().is_nan());
1352        assert!(parse_length("NaNpx").unwrap().is_nan());
1353        assert!(parse_length("-NaN%").unwrap().is_nan());
1354        assert!(parse_length("inf").unwrap().is_infinite());
1355        assert!(parse_length("infinity").unwrap().is_infinite());
1356        assert!(parse_length("INFpx").unwrap().is_infinite());
1357        assert!(parse_length("-inf").unwrap().is_sign_negative());
1358    }
1359
1360    #[test]
1361    fn length_preserves_the_sign_of_negative_zero() {
1362        let negative_zero = parse_length("-0px").unwrap();
1363        assert!(negative_zero == 0.0 && negative_zero.is_sign_negative());
1364        assert!(parse_length("0px").unwrap().is_sign_positive());
1365    }
1366
1367    #[test]
1368    fn length_handles_huge_digit_strings_without_hanging() {
1369        // 100k digits: the float parser's slow path is bounded, and the result
1370        // saturates rather than wrapping or panicking.
1371        let huge = format!("{}px", "9".repeat(100_000));
1372        assert!(parse_length(&huge).unwrap().is_infinite());
1373
1374        // 100k leading zeros still denote 1.0.
1375        let padded = format!("{}1px", "0".repeat(100_000));
1376        assert_eq!(parse_length(&padded).unwrap(), 1.0);
1377
1378        // 1M non-numeric chars must fail fast.
1379        let garbage = "q".repeat(1_000_000);
1380        assert!(parse_length(&garbage).is_err());
1381    }
1382
1383    #[test]
1384    fn length_error_payload_is_the_trimmed_input() {
1385        assert!(matches!(
1386            parse_length("  bogus  "),
1387            Err(ShapeParseError::InvalidNumber(ref n)) if n == "bogus"
1388        ));
1389        // The unit stays in the payload so the message shows what the user wrote.
1390        assert!(matches!(
1391            parse_length("bogus px"),
1392            Err(ShapeParseError::InvalidNumber(ref n)) if n == "bogus px"
1393        ));
1394    }
1395
1396    // =======================================================================
1397    // ShapeParseError::fmt (Display)
1398    // =======================================================================
1399
1400    #[test]
1401    fn error_display_is_non_empty_and_names_the_variant() {
1402        let cases = [
1403            (
1404                ShapeParseError::UnknownFunction("blob".into()),
1405                "Unknown shape function",
1406                "blob",
1407            ),
1408            (
1409                ShapeParseError::MissingParameter("radius".into()),
1410                "Missing required parameter",
1411                "radius",
1412            ),
1413            (
1414                ShapeParseError::InvalidNumber("12abc".into()),
1415                "Invalid numeric value",
1416                "12abc",
1417            ),
1418            (
1419                ShapeParseError::InvalidSyntax("bad parens".into()),
1420                "Invalid syntax",
1421                "bad parens",
1422            ),
1423        ];
1424        for (err, prefix, payload) in cases {
1425            let rendered = err.to_string();
1426            assert!(
1427                rendered.starts_with(prefix),
1428                "{rendered:?} lacks prefix {prefix:?}"
1429            );
1430            assert!(
1431                rendered.contains(payload),
1432                "{rendered:?} lost its payload {payload:?}"
1433            );
1434        }
1435        assert_eq!(ShapeParseError::EmptyInput.to_string(), "Empty input");
1436    }
1437
1438    #[test]
1439    fn error_display_survives_hostile_payloads() {
1440        // Empty, brace-laden (no format re-interpretation), unicode, control
1441        // chars and a 100k payload must all render without panicking.
1442        let payloads = [
1443            String::new(),
1444            "{}{0}{name}%s%n".to_string(),
1445            "\u{1F600}\u{0301}\u{202e}".to_string(),
1446            "\0\u{7}\n\t".to_string(),
1447            "x".repeat(100_000),
1448        ];
1449        for payload in payloads {
1450            for err in [
1451                ShapeParseError::UnknownFunction(payload.clone()),
1452                ShapeParseError::MissingParameter(payload.clone()),
1453                ShapeParseError::InvalidNumber(payload.clone()),
1454                ShapeParseError::InvalidSyntax(payload.clone()),
1455            ] {
1456                let rendered = catch_unwind(AssertUnwindSafe(|| err.to_string()));
1457                assert!(rendered.is_ok(), "Display panicked on payload {payload:?}");
1458                let rendered = rendered.unwrap();
1459                assert!(!rendered.is_empty());
1460                assert!(
1461                    rendered.ends_with(&payload),
1462                    "payload must be emitted literally, not re-formatted"
1463                );
1464            }
1465        }
1466    }
1467
1468    #[test]
1469    fn error_variants_render_distinctly_and_compare_by_value() {
1470        let same_payload = "x".to_string();
1471        let unknown = ShapeParseError::UnknownFunction(same_payload.clone());
1472        let missing = ShapeParseError::MissingParameter(same_payload.clone());
1473        assert_ne!(unknown, missing);
1474        assert_ne!(unknown.to_string(), missing.to_string());
1475        assert_eq!(unknown, ShapeParseError::UnknownFunction(same_payload));
1476        assert_eq!(unknown.clone(), unknown);
1477        // Debug must not be empty either (derive(Debug)).
1478        assert!(!format!("{unknown:?}").is_empty());
1479    }
1480
1481    // =======================================================================
1482    // Round-trip: print_as_css_value -> parse_shape
1483    // =======================================================================
1484
1485    fn assert_round_trips(shape: &CssShape) {
1486        let printed = shape.print_as_css_value();
1487        let reparsed =
1488            parse_shape(&printed).unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e}"));
1489        assert_eq!(
1490            &reparsed, shape,
1491            "round-trip changed the shape via {printed:?}"
1492        );
1493        // Printing the re-parsed value must be a fixed point.
1494        assert_eq!(reparsed.print_as_css_value(), printed);
1495    }
1496
1497    #[test]
1498    fn round_trip_every_shape_variant() {
1499        assert_round_trips(&CssShape::circle(ShapePoint::new(10.0, 20.0), 50.0));
1500        assert_round_trips(&CssShape::ellipse(ShapePoint::new(1.5, -2.5), 3.25, 4.75));
1501        assert_round_trips(&CssShape::polygon(
1502            vec![
1503                ShapePoint::new(0.0, 0.0),
1504                ShapePoint::new(100.0, 0.0),
1505                ShapePoint::new(50.0, 100.0),
1506            ]
1507            .into(),
1508        ));
1509        assert_round_trips(&CssShape::inset(1.0, 2.0, 3.0, 4.0));
1510        assert_round_trips(&CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0));
1511        assert_round_trips(&CssShape::Path(ShapePath {
1512            data: crate::corety::AzString::from("M 0 0 L 100 0 Z"),
1513        }));
1514    }
1515
1516    #[test]
1517    fn round_trip_survives_extreme_and_negative_numbers() {
1518        assert_round_trips(&CssShape::circle(
1519            ShapePoint::new(f32::MIN, f32::MAX),
1520            f32::MIN_POSITIVE,
1521        ));
1522        assert_round_trips(&CssShape::inset(-0.0, -1.5, 1e-30, 1e30));
1523        assert_round_trips(&CssShape::ellipse(
1524            ShapePoint::new(-0.000_001, 123_456.79),
1525            0.1,
1526            0.2,
1527        ));
1528    }
1529
1530    #[test]
1531    fn round_trip_of_non_finite_values_preserves_them() {
1532        // "{}" prints inf as "inf", which parse_length happily reads back — so
1533        // an inf radius survives a print/parse cycle instead of erroring out.
1534        let printed = CssShape::circle(ShapePoint::zero(), f32::INFINITY).print_as_css_value();
1535        assert_eq!(printed, "circle(infpx at 0px 0px)");
1536        assert!(circle_of(&parse_shape(&printed).unwrap())
1537            .radius
1538            .is_infinite());
1539
1540        // NaN != NaN, so assert_round_trips can't be used — check field-wise.
1541        let printed = CssShape::circle(ShapePoint::zero(), f32::NAN).print_as_css_value();
1542        assert!(circle_of(&parse_shape(&printed).unwrap()).radius.is_nan());
1543    }
1544
1545    #[test]
1546    fn round_trip_of_a_path_containing_quotes_and_parens() {
1547        // parse_function takes the LAST ')' and parse_path strips only the outer
1548        // quotes, so both survive an embedded ')' and an embedded '"'.
1549        for data in ["M 0 0)", ")", "a\"b", "", "M 0 0 L 1 1 Z"] {
1550            assert_round_trips(&CssShape::Path(ShapePath {
1551                data: crate::corety::AzString::from(data),
1552            }));
1553        }
1554    }
1555
1556    #[test]
1557    fn current_round_trip_is_asymmetric_for_degenerate_polygons() {
1558        // The printer will happily emit a 0/1/2-point polygon, but the parser
1559        // requires >= 3 points — so these shapes cannot survive a CSS round-trip.
1560        for count in 0..3 {
1561            let points: Vec<ShapePoint> = (0..count)
1562                .map(|i| ShapePoint::new(i as f32, i as f32))
1563                .collect();
1564            let printed = CssShape::polygon(points.into()).print_as_css_value();
1565            assert!(
1566                parse_shape(&printed).is_err(),
1567                "{printed:?} should not re-parse (fewer than 3 points)"
1568            );
1569        }
1570    }
1571
1572    #[test]
1573    fn round_trip_from_the_css_source_side_is_a_fixed_point() {
1574        // parse -> print -> parse must converge for author-written CSS.
1575        for source in [
1576            "circle(50px at 100px 100px)",
1577            "ellipse(50px 75px at 10px 20px)",
1578            "polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)",
1579            "inset(10px 20px 30px 40px)",
1580            "inset(10px round 5px)",
1581            "path(\"M 0 0 L 100 0 L 100 100 Z\")",
1582        ] {
1583            let first = parse_shape(source).unwrap();
1584            let second = parse_shape(&first.print_as_css_value()).unwrap();
1585            assert_eq!(first, second, "{source:?} is not a parse/print fixed point");
1586        }
1587    }
1588}