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

            
10
use crate::shape::{CssShape, ShapePoint};
11

            
12
/// Error type for shape parsing failures
13
#[derive(Debug, Clone, PartialEq, Eq)]
14
pub 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

            
27
impl core::fmt::Display for ShapeParseError {
28
27
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29
27
        match self {
30
7
            Self::UnknownFunction(func) => {
31
7
                write!(f, "Unknown shape function: {func}")
32
            }
33
7
            Self::MissingParameter(param) => {
34
7
                write!(f, "Missing required parameter: {param}")
35
            }
36
6
            Self::InvalidNumber(num) => {
37
6
                write!(f, "Invalid numeric value: {num}")
38
            }
39
6
            Self::InvalidSyntax(msg) => {
40
6
                write!(f, "Invalid syntax: {msg}")
41
            }
42
            Self::EmptyInput => {
43
1
                write!(f, "Empty input")
44
            }
45
        }
46
27
    }
47
}
48

            
49
/// Parses a CSS shape value
50
/// # Errors
51
///
52
/// Returns an error if `input` is not a valid CSS `shape` value.
53
1085
pub fn parse_shape(input: &str) -> Result<CssShape, ShapeParseError> {
54
1085
    let input = input.trim();
55

            
56
1085
    if input.is_empty() {
57
77
        return Err(ShapeParseError::EmptyInput);
58
1008
    }
59

            
60
    // Extract function name and arguments
61
1008
    let (func_name, args) = parse_function(input)?;
62

            
63
831
    match func_name.as_str() {
64
831
        "circle" => parse_circle(&args),
65
483
        "ellipse" => parse_ellipse(&args),
66
423
        "polygon" => parse_polygon(&args),
67
300
        "inset" => parse_inset(&args),
68
198
        "path" => parse_path(&args),
69
111
        _ => Err(ShapeParseError::UnknownFunction(func_name)),
70
    }
71
1085
}
72

            
73
/// Extracts function name and arguments from "func(args)"
74
1020
fn parse_function(
75
1020
    input: &str,
76
1020
) -> Result<(String, String), ShapeParseError> {
77
1020
    let open_paren = input
78
1020
        .find('(')
79
1020
        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing opening parenthesis".into()))?;
80

            
81
883
    let close_paren = input
82
883
        .rfind(')')
83
883
        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing closing parenthesis".into()))?;
84

            
85
847
    if close_paren <= open_paren {
86
11
        return Err(ShapeParseError::InvalidSyntax("Invalid parentheses".into()));
87
836
    }
88

            
89
836
    let func_name = input[..open_paren].trim().to_string();
90
836
    let args = input[open_paren + 1..close_paren].trim().to_string();
91

            
92
836
    Ok((func_name, args))
93
1020
}
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
101
357
fn parse_circle(args: &str) -> Result<CssShape, ShapeParseError> {
102
357
    let parts: Vec<&str> = args.split_whitespace().collect();
103

            
104
357
    if parts.is_empty() {
105
15
        return Err(ShapeParseError::MissingParameter("radius".into()));
106
342
    }
107

            
108
342
    let radius = parse_length(parts[0])?;
109

            
110
268
    let center = if parts.len() >= 4 && parts[1] == "at" {
111
58
        let x = parse_length(parts[2])?;
112
51
        let y = parse_length(parts[3])?;
113
50
        ShapePoint::new(x, y)
114
    } else {
115
210
        ShapePoint::zero() // Default to origin
116
    };
117

            
118
260
    Ok(CssShape::circle(center, radius))
119
357
}
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)
126
65
fn parse_ellipse(args: &str) -> Result<CssShape, ShapeParseError> {
127
65
    let parts: Vec<&str> = args.split_whitespace().collect();
128

            
129
65
    if parts.len() < 2 {
130
16
        return Err(ShapeParseError::MissingParameter(
131
16
            "radius_x and radius_y".into(),
132
16
        ));
133
49
    }
134

            
135
49
    let radius_x = parse_length(parts[0])?;
136
41
    let radius_y = parse_length(parts[1])?;
137

            
138
40
    let center = if parts.len() >= 5 && parts[2] == "at" {
139
25
        let x = parse_length(parts[3])?;
140
25
        let y = parse_length(parts[4])?;
141
25
        ShapePoint::new(x, y)
142
    } else {
143
15
        ShapePoint::zero()
144
    };
145

            
146
40
    Ok(CssShape::ellipse(center, radius_x, radius_y))
147
65
}
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
158
142
fn parse_polygon(args: &str) -> Result<CssShape, ShapeParseError> {
159
142
    let args = args.trim();
160

            
161
    // Check for optional fill-rule
162
142
    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
24
        let comma = args.find(',').unwrap();
165
24
        &args[comma + 1..]
166
    } else {
167
118
        args
168
    };
169

            
170
    // Split by comma to get coordinate pairs
171
142
    let pairs: Vec<&str> = point_str.split(',').map(str::trim).collect();
172

            
173
142
    if pairs.is_empty() {
174
        return Err(ShapeParseError::MissingParameter(
175
            "at least one point".into(),
176
        ));
177
142
    }
178

            
179
142
    let mut points = Vec::new();
180

            
181
92389
    for pair in pairs {
182
92309
        let coords: Vec<&str> = pair.split_whitespace().collect();
183

            
184
92309
        if coords.len() < 2 {
185
45
            return Err(ShapeParseError::InvalidSyntax(format!(
186
45
                "Expected x y pair, got: {pair}"
187
45
            )));
188
92264
        }
189

            
190
92264
        let x = parse_length(coords[0])?;
191
92247
        let y = parse_length(coords[1])?;
192

            
193
92247
        points.push(ShapePoint::new(x, y));
194
    }
195

            
196
80
    if points.len() < 3 {
197
19
        return Err(ShapeParseError::InvalidSyntax(
198
19
            "Polygon must have at least 3 points".into(),
199
19
        ));
200
61
    }
201

            
202
61
    Ok(CssShape::polygon(points.into()))
203
142
}
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
213
119
fn parse_inset(args: &str) -> Result<CssShape, ShapeParseError> {
214
119
    let args = args.trim();
215

            
216
    // Check for optional "round" keyword for border radius
217
119
    let (inset_str, border_radius) = if let Some(round_pos) = args.find("round") {
218
61
        let insets = args[..round_pos].trim();
219
61
        let radius_str = args[round_pos + 5..].trim();
220
61
        let radius = parse_length(radius_str)?;
221
29
        (insets, Some(radius))
222
    } else {
223
58
        (args, None)
224
    };
225

            
226
87
    let values: Vec<&str> = inset_str.split_whitespace().collect();
227

            
228
87
    if values.is_empty() {
229
23
        return Err(ShapeParseError::MissingParameter("inset values".into()));
230
64
    }
231

            
232
    // Parse insets using CSS shorthand rules (same as margin/padding)
233
64
    let (top, right, bottom, left) = match values.len() {
234
        1 => {
235
26
            let all = parse_length(values[0])?;
236
26
            (all, all, all, all)
237
        }
238
        2 => {
239
3
            let vertical = parse_length(values[0])?;
240
3
            let horizontal = parse_length(values[1])?;
241
3
            (vertical, horizontal, vertical, horizontal)
242
        }
243
        3 => {
244
2
            let top = parse_length(values[0])?;
245
2
            let horizontal = parse_length(values[1])?;
246
2
            let bottom = parse_length(values[2])?;
247
2
            (top, horizontal, bottom, horizontal)
248
        }
249
        4 => {
250
24
            let top = parse_length(values[0])?;
251
24
            let right = parse_length(values[1])?;
252
24
            let bottom = parse_length(values[2])?;
253
24
            let left = parse_length(values[3])?;
254
24
            (top, right, bottom, left)
255
        }
256
        _ => {
257
9
            return Err(ShapeParseError::InvalidSyntax(
258
9
                "Too many inset values (max 4)".into(),
259
9
            ));
260
        }
261
    };
262

            
263
55
    border_radius.map_or_else(
264
33
        || Ok(CssShape::inset(top, right, bottom, left)),
265
22
        |radius| Ok(CssShape::inset_rounded(top, right, bottom, left, radius)),
266
    )
267
119
}
268

            
269
/// Parses a path: `path("svg-path-data")`
270
///
271
/// Example:
272
/// - `path("M 0 0 L 100 0 L 100 100 Z")`
273
100
fn parse_path(args: &str) -> Result<CssShape, ShapeParseError> {
274
    use crate::corety::AzString;
275

            
276
100
    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
100
    if args.len() < 2 || !args.starts_with('"') || !args.ends_with('"') {
283
34
        return Err(ShapeParseError::InvalidSyntax(
284
34
            "Path data must be quoted".into(),
285
34
        ));
286
66
    }
287

            
288
66
    let path_data = AzString::from(&args[1..args.len() - 1]);
289

            
290
66
    Ok(CssShape::Path(crate::shape::ShapePath { data: path_data }))
291
100
}
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)
297
185355
fn parse_length(s: &str) -> Result<f32, ShapeParseError> {
298
185355
    let s = s.trim();
299

            
300
185355
    if let Some(num_str) = s.strip_suffix("px") {
301
184867
        num_str
302
184867
            .parse::<f32>()
303
184867
            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
304
488
    } else if let Some(num_str) = s.strip_suffix('%') {
305
15
        let percent = num_str
306
15
            .parse::<f32>()
307
15
            .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
13
        Ok(percent)
311
    } else {
312
        // Try to parse as unitless number (treat as px)
313
473
        s.parse::<f32>()
314
473
            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
315
    }
316
185355
}
317

            
318
#[cfg(test)]
319
mod 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
1
    fn test_parse_circle() {
330
1
        let shape = parse_shape("circle(50px at 100px 100px)").unwrap();
331
1
        match shape {
332
1
            CssShape::Circle(ShapeCircle { center, radius }) => {
333
1
                assert_eq!(radius, 50.0);
334
1
                assert_eq!(center.x, 100.0);
335
1
                assert_eq!(center.y, 100.0);
336
            }
337
            _ => panic!("Expected Circle"),
338
        }
339
1
    }
340

            
341
    #[test]
342
1
    fn test_parse_circle_no_position() {
343
1
        let shape = parse_shape("circle(50px)").unwrap();
344
1
        match shape {
345
1
            CssShape::Circle(ShapeCircle { center, radius }) => {
346
1
                assert_eq!(radius, 50.0);
347
1
                assert_eq!(center.x, 0.0);
348
1
                assert_eq!(center.y, 0.0);
349
            }
350
            _ => panic!("Expected Circle"),
351
        }
352
1
    }
353

            
354
    #[test]
355
1
    fn test_parse_ellipse() {
356
1
        let shape = parse_shape("ellipse(50px 75px at 100px 100px)").unwrap();
357
1
        match shape {
358
            CssShape::Ellipse(ShapeEllipse {
359
1
                center,
360
1
                radius_x,
361
1
                radius_y,
362
            }) => {
363
1
                assert_eq!(radius_x, 50.0);
364
1
                assert_eq!(radius_y, 75.0);
365
1
                assert_eq!(center.x, 100.0);
366
1
                assert_eq!(center.y, 100.0);
367
            }
368
            _ => panic!("Expected Ellipse"),
369
        }
370
1
    }
371

            
372
    #[test]
373
1
    fn test_parse_polygon_rectangle() {
374
1
        let shape = parse_shape("polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)").unwrap();
375
1
        match shape {
376
1
            CssShape::Polygon(ShapePolygon { points }) => {
377
1
                assert_eq!(points.as_ref().len(), 4);
378
1
                assert_eq!(points.as_ref()[0].x, 0.0);
379
1
                assert_eq!(points.as_ref()[0].y, 0.0);
380
1
                assert_eq!(points.as_ref()[2].x, 100.0);
381
1
                assert_eq!(points.as_ref()[2].y, 100.0);
382
            }
383
            _ => panic!("Expected Polygon"),
384
        }
385
1
    }
386

            
387
    #[test]
388
1
    fn test_parse_polygon_star() {
389
        // 5-pointed star
390
1
        let shape = parse_shape(
391
1
            "polygon(50px 0px, 61px 35px, 98px 35px, 68px 57px, 79px 91px, 50px 70px, 21px 91px, \
392
1
             32px 57px, 2px 35px, 39px 35px)",
393
        )
394
1
        .unwrap();
395
1
        match shape {
396
1
            CssShape::Polygon(ShapePolygon { points }) => {
397
1
                assert_eq!(points.as_ref().len(), 10); // 5-pointed star has 10 vertices
398
            }
399
            _ => panic!("Expected Polygon"),
400
        }
401
1
    }
402

            
403
    #[test]
404
1
    fn test_parse_inset() {
405
1
        let shape = parse_shape("inset(10px 20px 30px 40px)").unwrap();
406
1
        match shape {
407
            CssShape::Inset(ShapeInset {
408
1
                inset_top,
409
1
                inset_right,
410
1
                inset_bottom,
411
1
                inset_left,
412
1
                border_radius,
413
            }) => {
414
1
                assert_eq!(inset_top, 10.0);
415
1
                assert_eq!(inset_right, 20.0);
416
1
                assert_eq!(inset_bottom, 30.0);
417
1
                assert_eq!(inset_left, 40.0);
418
1
                assert!(matches!(border_radius, OptionF32::None));
419
            }
420
            _ => panic!("Expected Inset"),
421
        }
422
1
    }
423

            
424
    #[test]
425
1
    fn test_parse_inset_rounded() {
426
1
        let shape = parse_shape("inset(10px round 5px)").unwrap();
427
1
        match shape {
428
            CssShape::Inset(ShapeInset {
429
1
                inset_top,
430
1
                inset_right,
431
1
                inset_bottom,
432
1
                inset_left,
433
1
                border_radius,
434
            }) => {
435
1
                assert_eq!(inset_top, 10.0);
436
1
                assert_eq!(inset_right, 10.0);
437
1
                assert_eq!(inset_bottom, 10.0);
438
1
                assert_eq!(inset_left, 10.0);
439
1
                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
440
            }
441
            _ => panic!("Expected Inset"),
442
        }
443
1
    }
444

            
445
    #[test]
446
1
    fn test_parse_path() {
447
1
        let shape = parse_shape(r#"path("M 0 0 L 100 0 L 100 100 Z")"#).unwrap();
448
1
        match shape {
449
1
            CssShape::Path(ShapePath { data }) => {
450
1
                assert_eq!(data.as_str(), "M 0 0 L 100 0 L 100 100 Z");
451
            }
452
            _ => panic!("Expected Path"),
453
        }
454
1
    }
455

            
456
    #[test]
457
1
    fn test_invalid_function() {
458
1
        let result = parse_shape("unknown(50px)");
459
1
        assert!(result.is_err());
460
1
    }
461

            
462
    #[test]
463
1
    fn test_empty_input() {
464
1
        let result = parse_shape("");
465
1
        assert!(matches!(result, Err(ShapeParseError::EmptyInput)));
466
1
    }
467
}
468

            
469
#[cfg(test)]
470
#[allow(clippy::float_cmp, clippy::unreadable_literal)]
471
mod 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());
        assert_eq!(nonzero.len(), 3);
        assert_eq!(nonzero[0], ShapePoint::zero());
        let evenodd = polygon_points(&parse_polygon("evenodd, 0 0, 1 0, 1 1").unwrap());
        assert_eq!(evenodd.len(), 3);
        // The prefix check is `starts_with("nonzero,")` — a space before the
        // comma defeats it, and the keyword then fails as a coordinate.
        assert!(matches!(
            parse_polygon("nonzero , 0 0, 1 0, 1 1"),
            Err(ShapeParseError::InvalidSyntax(_))
        ));
        // No comma at all: "nonzero" is read as an x coordinate.
        assert!(matches!(
            parse_polygon("nonzero 0 0, 1 0, 1 1"),
            Err(ShapeParseError::InvalidNumber(ref n)) if n == "nonzero"
        ));
        // Fill rule with no points after it.
        assert!(parse_polygon("nonzero,").is_err());
    }
    #[test]
    fn current_polygon_ignores_extra_coordinates_in_a_pair() {
        // Only coords[0] and coords[1] are read; a stray third value is dropped.
        let points = polygon_points(&parse_polygon("0 0 999, 1 1 999, 2 2 999").unwrap());
        assert_eq!(points.len(), 3);
        assert_eq!(points[2], ShapePoint::new(2.0, 2.0));
    }
    #[test]
    fn current_polygon_mixes_units_silently() {
        // px, %, and unitless all collapse to the same raw f32 today.
        let points = polygon_points(&parse_polygon("0% 0px, 100 0%, 50px 100").unwrap());
        assert_eq!(points[1], ShapePoint::new(100.0, 0.0));
        assert_eq!(points[2], ShapePoint::new(50.0, 100.0));
    }
    #[test]
    fn polygon_non_finite_coordinates_do_not_panic() {
        let points = polygon_points(&parse_polygon("NaN 0, inf 1, -inf 2").unwrap());
        assert!(points[0].x.is_nan());
        assert!(points[1].x.is_infinite() && points[1].x.is_sign_positive());
        assert!(points[2].x.is_infinite() && points[2].x.is_sign_negative());
    }
    #[test]
    fn polygon_with_twenty_thousand_points_does_not_hang() {
        let mut args = String::with_capacity(20_000 * 10);
        for i in 0..20_000 {
            if i > 0 {
                args.push(',');
            }
            args.push_str("1px 2px");
        }
        let points = polygon_points(&parse_polygon(&args).unwrap());
        assert_eq!(points.len(), 20_000);
        assert_eq!(points[19_999], ShapePoint::new(1.0, 2.0));
    }
    // =======================================================================
    // parse_inset
    // =======================================================================
    #[test]
    fn inset_empty_args_report_missing_values() {
        assert!(matches!(
            parse_inset(""),
            Err(ShapeParseError::MissingParameter(ref p)) if p.contains("inset values")
        ));
        assert!(matches!(
            parse_inset("   "),
            Err(ShapeParseError::MissingParameter(_))
        ));
    }
    #[test]
    fn inset_shorthand_expansion_follows_the_margin_rules() {
        let one = inset_of(&parse_inset("10px").unwrap());
        assert_eq!(
            (one.inset_top, one.inset_right, one.inset_bottom, one.inset_left),
            (10.0, 10.0, 10.0, 10.0)
        );
        let two = inset_of(&parse_inset("10px 20px").unwrap());
        assert_eq!(
            (two.inset_top, two.inset_right, two.inset_bottom, two.inset_left),
            (10.0, 20.0, 10.0, 20.0)
        );
        let three = inset_of(&parse_inset("10px 20px 30px").unwrap());
        assert_eq!(
            (three.inset_top, three.inset_right, three.inset_bottom, three.inset_left),
            (10.0, 20.0, 30.0, 20.0)
        );
        let four = inset_of(&parse_inset("10px 20px 30px 40px").unwrap());
        assert_eq!(
            (four.inset_top, four.inset_right, four.inset_bottom, four.inset_left),
            (10.0, 20.0, 30.0, 40.0)
        );
    }
    #[test]
    fn inset_rejects_more_than_four_values() {
        assert!(matches!(
            parse_inset("1px 2px 3px 4px 5px"),
            Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("max 4")
        ));
        // Long runs must hit the same guard, not allocate their way through.
        let many = ["1px"; 1_000].join(" ");
        assert!(matches!(
            parse_inset(&many),
            Err(ShapeParseError::InvalidSyntax(_))
        ));
    }
    #[test]
    fn inset_round_keyword_splits_insets_from_the_radius() {
        let rounded = inset_of(&parse_inset("10px 20px round 5px").unwrap());
        assert_eq!(rounded.inset_top, 10.0);
        assert_eq!(rounded.inset_right, 20.0);
        assert!(matches!(rounded.border_radius, OptionF32::Some(r) if r == 5.0));
        let plain = inset_of(&parse_inset("10px").unwrap());
        assert!(matches!(plain.border_radius, OptionF32::None));
    }
    #[test]
    fn inset_malformed_round_clauses_are_rejected() {
        // "round" with no radius after it.
        assert!(matches!(
            parse_inset("10px round"),
            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
        ));
        // "round" with no insets before it: the radius parses, then the value
        // list comes up empty.
        assert!(matches!(
            parse_inset("round 5px"),
            Err(ShapeParseError::MissingParameter(_))
        ));
        // A second "round" lands inside the radius token and fails to parse.
        assert!(parse_inset("10px round 5px round 6px").is_err());
        // `find("round")` is a substring search, so "roundup" also triggers the
        // split — and then fails on the leftover "up".
        assert!(matches!(
            parse_inset("10px roundup"),
            Err(ShapeParseError::InvalidNumber(ref n)) if n == "up"
        ));
    }
    #[test]
    fn inset_non_finite_and_negative_values_do_not_panic() {
        let nan = inset_of(&parse_inset("NaN").unwrap());
        assert!(nan.inset_top.is_nan() && nan.inset_left.is_nan());
        let huge = inset_of(&parse_inset("1e39px round 1e39px").unwrap());
        assert!(huge.inset_top.is_infinite());
        assert!(matches!(huge.border_radius, OptionF32::Some(r) if r.is_infinite()));
        // Negative insets/radii are accepted (CSS rejects a negative radius).
        let negative = inset_of(&parse_inset("-10px round -5px").unwrap());
        assert_eq!(negative.inset_top, -10.0);
        assert!(matches!(negative.border_radius, OptionF32::Some(r) if r == -5.0));
    }
    // =======================================================================
    // parse_path
    // =======================================================================
    #[test]
    fn path_requires_double_quotes_on_both_ends() {
        for unquoted in [
            "",
            "   ",
            "M 0 0",
            "\"M 0 0",
            "M 0 0\"",
            "'M 0 0'", // single quotes are not accepted
            "`M 0 0`",
        ] {
            let parsed = catch_unwind(AssertUnwindSafe(|| parse_path(unquoted)));
            assert!(parsed.is_ok(), "parse_path({unquoted:?}) panicked");
            assert!(
                matches!(parsed.unwrap(), Err(ShapeParseError::InvalidSyntax(ref m)) if m.contains("quoted")),
                "parse_path({unquoted:?}) should be an unquoted-syntax error"
            );
        }
    }
    #[test]
    fn path_strips_exactly_one_quote_from_each_end() {
        assert_eq!(path_data(&parse_path("\"\"").unwrap()), "");
        assert_eq!(path_data(&parse_path("\"M 0 0 Z\"").unwrap()), "M 0 0 Z");
        // Inner quotes are preserved verbatim.
        assert_eq!(path_data(&parse_path("\"a\"b\"").unwrap()), "a\"b");
    }
    #[test]
    fn current_path_data_is_stored_without_validation() {
        // ShapePath's doc says the data is stored but not interpreted, so any
        // garbage inside the quotes round-trips untouched.
        assert_eq!(path_data(&parse_shape("path(\"not svg at all\")").unwrap()), "not svg at all");
        assert_eq!(path_data(&parse_shape("path(\"\u{1F600}\")").unwrap()), "\u{1F600}");
        assert_eq!(path_data(&parse_shape("path(\"M 0 0 L NaN inf\")").unwrap()), "M 0 0 L NaN inf");
    }
    #[test]
    fn path_multibyte_content_slices_on_char_boundaries() {
        // args[1..len-1] indexes bytes: a 4-byte emoji adjacent to each quote is
        // the case that would split a codepoint if the bounds were wrong.
        let data = path_data(&parse_path("\"\u{1F600}\u{0301}\u{1F600}\"").unwrap());
        assert_eq!(data, "\u{1F600}\u{0301}\u{1F600}");
    }
    #[test]
    fn path_with_a_megabyte_of_data_does_not_hang() {
        let inner = "L 1 1 ".repeat(150_000);
        let arg = format!("\"{inner}\"");
        assert_eq!(path_data(&parse_path(&arg).unwrap()), inner);
    }
    // =======================================================================
    // parse_length
    // =======================================================================
    #[test]
    fn length_empty_and_whitespace_are_invalid_numbers() {
        assert!(matches!(
            parse_length(""),
            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
        ));
        assert!(matches!(
            parse_length("  \t\n "),
            Err(ShapeParseError::InvalidNumber(ref n)) if n.is_empty()
        ));
        // Bare units with no number.
        assert!(matches!(
            parse_length("px"),
            Err(ShapeParseError::InvalidNumber(ref n)) if n == "px"
        ));
        assert!(parse_length("%").is_err());
    }
    #[test]
    fn length_accepts_px_percent_and_unitless() {
        assert_eq!(parse_length("50px").unwrap(), 50.0);
        assert_eq!(parse_length("50%").unwrap(), 50.0);
        assert_eq!(parse_length("50").unwrap(), 50.0);
        assert_eq!(parse_length("  50px  ").unwrap(), 50.0);
        assert_eq!(parse_length("+5px").unwrap(), 5.0);
        assert_eq!(parse_length(".5px").unwrap(), 0.5);
        assert_eq!(parse_length("5.px").unwrap(), 5.0);
        assert_eq!(parse_length("5e2px").unwrap(), 500.0);
    }
    #[test]
    fn current_length_rejects_uppercase_units_and_other_css_units() {
        // CSS units are case-insensitive and em/rem/vh/vw/pt are all legal; the
        // source has a TODO for the latter. Both are rejected today.
        for unsupported in ["50PX", "50Px", "1em", "1rem", "1vh", "1vw", "1pt", "1cm", "1fr"] {
            assert!(
                parse_length(unsupported).is_err(),
                "parse_length({unsupported:?}) unexpectedly succeeded"
            );
        }
    }
    #[test]
    fn length_rejects_non_css_numeric_syntax() {
        for bad in [
            "1_000px", "0x10px", "1,5px", "50px50px", "50%%", "50%px", "--5px", "5 0px", "50 px",
            "1e", "e5", "0b101", "١٠px", // arabic-indic digits
        ] {
            let parsed = catch_unwind(AssertUnwindSafe(|| parse_length(bad)));
            assert!(parsed.is_ok(), "parse_length({bad:?}) panicked");
            assert!(
                parsed.unwrap().is_err(),
                "parse_length({bad:?}) unexpectedly succeeded"
            );
        }
    }
    #[test]
    fn length_saturates_on_overflow_and_flushes_on_underflow() {
        assert!(parse_length("1e39").unwrap().is_infinite());
        assert!(parse_length("1e39px").unwrap().is_sign_positive());
        assert!(parse_length("-1e39px").unwrap().is_sign_negative());
        assert_eq!(parse_length("1e-46px").unwrap(), 0.0);
        assert_eq!(parse_length("-1e-46px").unwrap(), -0.0);
        // f32 boundary values survive exactly.
        assert_eq!(parse_length(&format!("{}px", f32::MAX)).unwrap(), f32::MAX);
        assert_eq!(parse_length(&format!("{}px", f32::MIN)).unwrap(), f32::MIN);
        assert_eq!(
            parse_length(&format!("{}px", f32::MIN_POSITIVE)).unwrap(),
            f32::MIN_POSITIVE
        );
        // A double-precision value beyond f32 range clamps to inf, not to a wrap.
        assert!(parse_length(&format!("{}px", f64::MAX)).unwrap().is_infinite());
    }
    #[test]
    fn current_length_accepts_nan_and_infinity_spellings() {
        // f32::from_str parses "NaN"/"inf"/"infinity" (case-insensitively), so
        // these reach the shape structs as non-finite radii/coordinates. CSS has
        // no such tokens — a future tightening should reject them here.
        assert!(parse_length("NaN").unwrap().is_nan());
        assert!(parse_length("nan").unwrap().is_nan());
        assert!(parse_length("NaNpx").unwrap().is_nan());
        assert!(parse_length("-NaN%").unwrap().is_nan());
        assert!(parse_length("inf").unwrap().is_infinite());
        assert!(parse_length("infinity").unwrap().is_infinite());
        assert!(parse_length("INFpx").unwrap().is_infinite());
        assert!(parse_length("-inf").unwrap().is_sign_negative());
    }
    #[test]
    fn length_preserves_the_sign_of_negative_zero() {
        let negative_zero = parse_length("-0px").unwrap();
        assert!(negative_zero == 0.0 && negative_zero.is_sign_negative());
        assert!(parse_length("0px").unwrap().is_sign_positive());
    }
    #[test]
    fn length_handles_huge_digit_strings_without_hanging() {
        // 100k digits: the float parser's slow path is bounded, and the result
        // saturates rather than wrapping or panicking.
        let huge = format!("{}px", "9".repeat(100_000));
        assert!(parse_length(&huge).unwrap().is_infinite());
        // 100k leading zeros still denote 1.0.
        let padded = format!("{}1px", "0".repeat(100_000));
        assert_eq!(parse_length(&padded).unwrap(), 1.0);
        // 1M non-numeric chars must fail fast.
        let garbage = "q".repeat(1_000_000);
        assert!(parse_length(&garbage).is_err());
    }
    #[test]
    fn length_error_payload_is_the_trimmed_input() {
        assert!(matches!(
            parse_length("  bogus  "),
            Err(ShapeParseError::InvalidNumber(ref n)) if n == "bogus"
        ));
        // The unit stays in the payload so the message shows what the user wrote.
        assert!(matches!(
            parse_length("bogus px"),
            Err(ShapeParseError::InvalidNumber(ref n)) if n == "bogus px"
        ));
    }
    // =======================================================================
    // ShapeParseError::fmt (Display)
    // =======================================================================
    #[test]
    fn error_display_is_non_empty_and_names_the_variant() {
        let cases = [
            (ShapeParseError::UnknownFunction("blob".into()), "Unknown shape function", "blob"),
            (ShapeParseError::MissingParameter("radius".into()), "Missing required parameter", "radius"),
            (ShapeParseError::InvalidNumber("12abc".into()), "Invalid numeric value", "12abc"),
            (ShapeParseError::InvalidSyntax("bad parens".into()), "Invalid syntax", "bad parens"),
        ];
        for (err, prefix, payload) in cases {
            let rendered = err.to_string();
            assert!(rendered.starts_with(prefix), "{rendered:?} lacks prefix {prefix:?}");
            assert!(rendered.contains(payload), "{rendered:?} lost its payload {payload:?}");
        }
        assert_eq!(ShapeParseError::EmptyInput.to_string(), "Empty input");
    }
    #[test]
    fn error_display_survives_hostile_payloads() {
        // Empty, brace-laden (no format re-interpretation), unicode, control
        // chars and a 100k payload must all render without panicking.
        let payloads = [
            String::new(),
            "{}{0}{name}%s%n".to_string(),
            "\u{1F600}\u{0301}\u{202e}".to_string(),
            "\0\u{7}\n\t".to_string(),
            "x".repeat(100_000),
        ];
        for payload in payloads {
            for err in [
                ShapeParseError::UnknownFunction(payload.clone()),
                ShapeParseError::MissingParameter(payload.clone()),
                ShapeParseError::InvalidNumber(payload.clone()),
                ShapeParseError::InvalidSyntax(payload.clone()),
            ] {
                let rendered = catch_unwind(AssertUnwindSafe(|| err.to_string()));
                assert!(rendered.is_ok(), "Display panicked on payload {payload:?}");
                let rendered = rendered.unwrap();
                assert!(!rendered.is_empty());
                assert!(
                    rendered.ends_with(&payload),
                    "payload must be emitted literally, not re-formatted"
                );
            }
        }
    }
    #[test]
    fn error_variants_render_distinctly_and_compare_by_value() {
        let same_payload = "x".to_string();
        let unknown = ShapeParseError::UnknownFunction(same_payload.clone());
        let missing = ShapeParseError::MissingParameter(same_payload.clone());
        assert_ne!(unknown, missing);
        assert_ne!(unknown.to_string(), missing.to_string());
        assert_eq!(unknown, ShapeParseError::UnknownFunction(same_payload));
        assert_eq!(unknown.clone(), unknown);
        // Debug must not be empty either (derive(Debug)).
        assert!(!format!("{unknown:?}").is_empty());
    }
    // =======================================================================
    // Round-trip: print_as_css_value -> parse_shape
    // =======================================================================
    fn assert_round_trips(shape: &CssShape) {
        let printed = shape.print_as_css_value();
        let reparsed = parse_shape(&printed)
            .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e}"));
        assert_eq!(
            &reparsed, shape,
            "round-trip changed the shape via {printed:?}"
        );
        // Printing the re-parsed value must be a fixed point.
        assert_eq!(reparsed.print_as_css_value(), printed);
    }
    #[test]
    fn round_trip_every_shape_variant() {
        assert_round_trips(&CssShape::circle(ShapePoint::new(10.0, 20.0), 50.0));
        assert_round_trips(&CssShape::ellipse(ShapePoint::new(1.5, -2.5), 3.25, 4.75));
        assert_round_trips(&CssShape::polygon(
            vec![
                ShapePoint::new(0.0, 0.0),
                ShapePoint::new(100.0, 0.0),
                ShapePoint::new(50.0, 100.0),
            ]
            .into(),
        ));
        assert_round_trips(&CssShape::inset(1.0, 2.0, 3.0, 4.0));
        assert_round_trips(&CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0));
        assert_round_trips(&CssShape::Path(ShapePath {
            data: crate::corety::AzString::from("M 0 0 L 100 0 Z"),
        }));
    }
    #[test]
    fn round_trip_survives_extreme_and_negative_numbers() {
        assert_round_trips(&CssShape::circle(
            ShapePoint::new(f32::MIN, f32::MAX),
            f32::MIN_POSITIVE,
        ));
        assert_round_trips(&CssShape::inset(-0.0, -1.5, 1e-30, 1e30));
        assert_round_trips(&CssShape::ellipse(
            ShapePoint::new(-0.000_001, 123_456.79),
            0.1,
            0.2,
        ));
    }
    #[test]
    fn round_trip_of_non_finite_values_preserves_them() {
        // "{}" prints inf as "inf", which parse_length happily reads back — so
        // an inf radius survives a print/parse cycle instead of erroring out.
        let printed = CssShape::circle(ShapePoint::zero(), f32::INFINITY).print_as_css_value();
        assert_eq!(printed, "circle(infpx at 0px 0px)");
        assert!(circle_of(&parse_shape(&printed).unwrap()).radius.is_infinite());
        // NaN != NaN, so assert_round_trips can't be used — check field-wise.
        let printed = CssShape::circle(ShapePoint::zero(), f32::NAN).print_as_css_value();
        assert!(circle_of(&parse_shape(&printed).unwrap()).radius.is_nan());
    }
    #[test]
    fn round_trip_of_a_path_containing_quotes_and_parens() {
        // parse_function takes the LAST ')' and parse_path strips only the outer
        // quotes, so both survive an embedded ')' and an embedded '"'.
        for data in ["M 0 0)", ")", "a\"b", "", "M 0 0 L 1 1 Z"] {
            assert_round_trips(&CssShape::Path(ShapePath {
                data: crate::corety::AzString::from(data),
            }));
        }
    }
    #[test]
    fn current_round_trip_is_asymmetric_for_degenerate_polygons() {
        // The printer will happily emit a 0/1/2-point polygon, but the parser
        // requires >= 3 points — so these shapes cannot survive a CSS round-trip.
        for count in 0..3 {
            let points: Vec<ShapePoint> =
                (0..count).map(|i| ShapePoint::new(i as f32, i as f32)).collect();
            let printed = CssShape::polygon(points.into()).print_as_css_value();
            assert!(
                parse_shape(&printed).is_err(),
                "{printed:?} should not re-parse (fewer than 3 points)"
            );
        }
    }
    #[test]
    fn round_trip_from_the_css_source_side_is_a_fixed_point() {
        // parse -> print -> parse must converge for author-written CSS.
        for source in [
            "circle(50px at 100px 100px)",
            "ellipse(50px 75px at 10px 20px)",
            "polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)",
            "inset(10px 20px 30px 40px)",
            "inset(10px round 5px)",
            "path(\"M 0 0 L 100 0 L 100 100 Z\")",
        ] {
            let first = parse_shape(source).unwrap();
            let second = parse_shape(&first.print_as_css_value()).unwrap();
            assert_eq!(first, second, "{source:?} is not a parse/print fixed point");
        }
    }
}