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