Skip to main content

azul_css/props/layout/
shape.rs

1//! CSS properties for flowing content around shapes (CSS Shapes Module).
2//!
3//! Defines [`ShapeOutside`], [`ShapeInside`], [`ClipPath`], [`ShapeMargin`],
4//! and [`ShapeImageThreshold`]. Note: `ClipPath` belongs to CSS Masking but
5//! is co-located here for convenience.
6
7use alloc::string::{String, ToString};
8
9use crate::{
10    props::{
11        basic::{
12            length::{parse_float_value, FloatValue},
13            pixel::{parse_pixel_value, CssPixelValueParseError, PixelValue},
14        },
15        formatter::PrintAsCssValue,
16    },
17    shape::CssShape,
18};
19#[allow(variant_size_differences)]
20// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
21/// CSS shape-outside property for wrapping text around shapes
22#[derive(Debug, Clone, PartialEq)]
23#[repr(C, u8)]
24#[derive(Default)]
25pub enum ShapeOutside {
26    #[default]
27    None,
28    Shape(CssShape),
29}
30
31impl Eq for ShapeOutside {}
32impl core::hash::Hash for ShapeOutside {
33    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
34        core::mem::discriminant(self).hash(state);
35        if let Self::Shape(s) = self {
36            s.hash(state);
37        }
38    }
39}
40impl PartialOrd for ShapeOutside {
41    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
42        Some(self.cmp(other))
43    }
44}
45impl Ord for ShapeOutside {
46    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
47        match (self, other) {
48            (Self::None, Self::None) => core::cmp::Ordering::Equal,
49            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
50            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
51            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
52        }
53    }
54}
55
56impl PrintAsCssValue for ShapeOutside {
57    fn print_as_css_value(&self) -> String {
58        match self {
59            Self::None => "none".to_string(),
60            Self::Shape(shape) => shape.print_as_css_value(),
61        }
62    }
63}
64#[allow(variant_size_differences)]
65// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
66/// CSS shape-inside property for flowing text within shapes
67#[derive(Debug, Clone, PartialEq)]
68#[repr(C, u8)]
69#[derive(Default)]
70pub enum ShapeInside {
71    #[default]
72    None,
73    Shape(CssShape),
74}
75
76impl Eq for ShapeInside {}
77impl core::hash::Hash for ShapeInside {
78    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
79        core::mem::discriminant(self).hash(state);
80        if let Self::Shape(s) = self {
81            s.hash(state);
82        }
83    }
84}
85impl PartialOrd for ShapeInside {
86    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
87        Some(self.cmp(other))
88    }
89}
90impl Ord for ShapeInside {
91    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
92        match (self, other) {
93            (Self::None, Self::None) => core::cmp::Ordering::Equal,
94            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
95            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
96            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
97        }
98    }
99}
100
101impl PrintAsCssValue for ShapeInside {
102    fn print_as_css_value(&self) -> String {
103        match self {
104            Self::None => "none".to_string(),
105            Self::Shape(shape) => shape.print_as_css_value(),
106        }
107    }
108}
109#[allow(variant_size_differences)]
110// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
111/// CSS clip-path property for clipping element rendering
112#[derive(Debug, Clone, PartialEq)]
113#[repr(C, u8)]
114#[derive(Default)]
115pub enum ClipPath {
116    #[default]
117    None,
118    Shape(CssShape),
119}
120
121impl Eq for ClipPath {}
122impl core::hash::Hash for ClipPath {
123    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
124        core::mem::discriminant(self).hash(state);
125        if let Self::Shape(s) = self {
126            s.hash(state);
127        }
128    }
129}
130impl PartialOrd for ClipPath {
131    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
132        Some(self.cmp(other))
133    }
134}
135impl Ord for ClipPath {
136    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
137        match (self, other) {
138            (Self::None, Self::None) => core::cmp::Ordering::Equal,
139            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
140            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
141            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
142        }
143    }
144}
145
146impl PrintAsCssValue for ClipPath {
147    fn print_as_css_value(&self) -> String {
148        match self {
149            Self::None => "none".to_string(),
150            Self::Shape(shape) => shape.print_as_css_value(),
151        }
152    }
153}
154
155/// CSS `shape-margin` property — adds margin to the shape-outside exclusion area.
156#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
157#[repr(C)]
158pub struct ShapeMargin {
159    pub inner: PixelValue,
160}
161
162impl Default for ShapeMargin {
163    fn default() -> Self {
164        Self {
165            inner: PixelValue::zero(),
166        }
167    }
168}
169
170impl PrintAsCssValue for ShapeMargin {
171    fn print_as_css_value(&self) -> String {
172        self.inner.print_as_css_value()
173    }
174}
175
176/// CSS `shape-image-threshold` property — alpha threshold for image-based shapes.
177#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
178#[repr(C)]
179pub struct ShapeImageThreshold {
180    pub inner: FloatValue,
181}
182
183impl Default for ShapeImageThreshold {
184    fn default() -> Self {
185        Self {
186            inner: FloatValue::const_new(0),
187        }
188    }
189}
190
191impl PrintAsCssValue for ShapeImageThreshold {
192    fn print_as_css_value(&self) -> String {
193        self.inner.to_string()
194    }
195}
196
197// Formatting to Rust code
198impl crate::codegen::format::FormatAsRustCode for ShapeOutside {
199    fn format_as_rust_code(&self, _tabs: usize) -> String {
200        match self {
201            Self::None => String::from("ShapeOutside::None"),
202            Self::Shape(s) => {
203                let mut r = String::from("ShapeOutside::Shape(");
204                r.push_str(&s.format_as_rust_code());
205                r.push(')');
206                r
207            }
208        }
209    }
210}
211
212impl crate::codegen::format::FormatAsRustCode for ShapeInside {
213    fn format_as_rust_code(&self, _tabs: usize) -> String {
214        match self {
215            Self::None => String::from("ShapeInside::None"),
216            Self::Shape(s) => {
217                let mut r = String::from("ShapeInside::Shape(");
218                r.push_str(&s.format_as_rust_code());
219                r.push(')');
220                r
221            }
222        }
223    }
224}
225
226impl crate::codegen::format::FormatAsRustCode for ClipPath {
227    fn format_as_rust_code(&self, _tabs: usize) -> String {
228        match self {
229            Self::None => String::from("ClipPath::None"),
230            Self::Shape(s) => {
231                let mut r = String::from("ClipPath::Shape(");
232                r.push_str(&s.format_as_rust_code());
233                r.push(')');
234                r
235            }
236        }
237    }
238}
239
240impl crate::codegen::format::FormatAsRustCode for ShapeMargin {
241    fn format_as_rust_code(&self, _tabs: usize) -> String {
242        format!(
243            "ShapeMargin {{ inner: {} }}",
244            crate::codegen::format::format_pixel_value(&self.inner)
245        )
246    }
247}
248
249impl crate::codegen::format::FormatAsRustCode for ShapeImageThreshold {
250    fn format_as_rust_code(&self, _tabs: usize) -> String {
251        format!(
252            "ShapeImageThreshold {{ inner: {} }}",
253            crate::codegen::format::format_float_value(&self.inner)
254        )
255    }
256}
257
258// --- PARSERS ---
259#[cfg(feature = "parser")]
260pub mod parser {
261    use core::num::ParseFloatError;
262
263    #[allow(clippy::wildcard_imports)]
264    // parser submodule reuses the parent module's value types
265    use super::*;
266    use crate::shape_parser::{parse_shape, ShapeParseError};
267
268    /// Parser for shape-outside property
269    /// # Errors
270    ///
271    /// Returns an error if `input` is not a valid CSS `shape-outside` value.
272    pub fn parse_shape_outside(input: &str) -> Result<ShapeOutside, ShapeParseError> {
273        let trimmed = input.trim();
274        if trimmed == "none" {
275            Ok(ShapeOutside::None)
276        } else {
277            let shape = parse_shape(trimmed)?;
278            Ok(ShapeOutside::Shape(shape))
279        }
280    }
281
282    /// Parser for shape-inside property
283    /// # Errors
284    ///
285    /// Returns an error if `input` is not a valid CSS `shape-inside` value.
286    pub fn parse_shape_inside(input: &str) -> Result<ShapeInside, ShapeParseError> {
287        let trimmed = input.trim();
288        if trimmed == "none" {
289            Ok(ShapeInside::None)
290        } else {
291            let shape = parse_shape(trimmed)?;
292            Ok(ShapeInside::Shape(shape))
293        }
294    }
295
296    /// Parser for clip-path property
297    /// # Errors
298    ///
299    /// Returns an error if `input` is not a valid CSS `clip-path` value.
300    pub fn parse_clip_path(input: &str) -> Result<ClipPath, ShapeParseError> {
301        let trimmed = input.trim();
302        if trimmed == "none" {
303            Ok(ClipPath::None)
304        } else {
305            let shape = parse_shape(trimmed)?;
306            Ok(ClipPath::Shape(shape))
307        }
308    }
309
310    /// Parser for shape-margin property
311    /// # Errors
312    ///
313    /// Returns an error if `input` is not a valid CSS `shape-margin` value.
314    pub fn parse_shape_margin(input: &str) -> Result<ShapeMargin, CssPixelValueParseError<'_>> {
315        Ok(ShapeMargin {
316            inner: parse_pixel_value(input)?,
317        })
318    }
319
320    /// Parser for shape-image-threshold property
321    /// # Errors
322    ///
323    /// Returns an error if `input` is not a valid CSS `shape-image-threshold` value.
324    pub fn parse_shape_image_threshold(
325        input: &str,
326    ) -> Result<ShapeImageThreshold, ParseFloatError> {
327        let val = parse_float_value(input)?;
328        // value should be clamped between 0.0 and 1.0
329        let clamped = val.get().clamp(0.0, 1.0);
330        Ok(ShapeImageThreshold {
331            inner: FloatValue::new(clamped),
332        })
333    }
334}
335
336#[cfg(feature = "parser")]
337pub use parser::*;
338
339#[cfg(all(test, feature = "parser"))]
340mod tests {
341    // Tests assert that parsed values equal the exact source literals.
342    #![allow(clippy::float_cmp)]
343    use super::*;
344
345    #[test]
346    fn test_parse_shape_properties() {
347        // Test shape-outside
348        assert!(matches!(
349            parse_shape_outside("none").unwrap(),
350            ShapeOutside::None
351        ));
352        assert!(matches!(
353            parse_shape_outside("circle(50px)").unwrap(),
354            ShapeOutside::Shape(_)
355        ));
356
357        // Test shape-inside
358        assert!(matches!(
359            parse_shape_inside("none").unwrap(),
360            ShapeInside::None
361        ));
362        assert!(matches!(
363            parse_shape_inside("circle(100px at 50px 50px)").unwrap(),
364            ShapeInside::Shape(_)
365        ));
366
367        // Test clip-path
368        assert!(matches!(parse_clip_path("none").unwrap(), ClipPath::None));
369        assert!(matches!(
370            parse_clip_path("polygon(0 0, 100px 0, 100px 100px, 0 100px)").unwrap(),
371            ClipPath::Shape(_)
372        ));
373
374        // Test existing properties
375        assert_eq!(
376            parse_shape_margin("10px").unwrap().inner,
377            PixelValue::px(10.0)
378        );
379        assert_eq!(parse_shape_image_threshold("0.5").unwrap().inner.get(), 0.5);
380    }
381}
382
383#[cfg(all(test, feature = "parser"))]
384mod autotest_generated {
385    //! Adversarial tests for the five `shape.rs` parsers.
386    //!
387    //! Three of these tests are *characterization* tests: they pin down current
388    //! behaviour that is a genuine defect in code this module calls into
389    //! (`shape_parser` / `props::basic::pixel`). Each is marked `KNOWN BUG`, and
390    //! each is written so that it FAILS THE DAY THE BUG IS FIXED, with a message
391    //! saying what to replace it with. They are tripwires, not endorsements.
392
393    // float_cmp: parsed values are compared against the exact literals they were
394    // built from. eq_op: several tests compare a value with itself on purpose —
395    // reflexivity of PartialEq is precisely what is under test.
396    #![allow(clippy::float_cmp, clippy::eq_op)]
397
398    use core::{cmp::Ordering, hash::Hash};
399
400    use super::*;
401    use crate::{
402        corety::OptionF32,
403        props::basic::length::SizeMetric,
404        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
405        shape_parser::ShapeParseError,
406    };
407
408    /// Inputs that USED to make `shape_parser::parse_path` panic (a lone `"` argument
409    /// satisfies both `starts_with('"')` and `ends_with('"')`, so the parser sliced
410    /// `[1..0]`). Now fixed — these return `Err`. Kept as a corpus of formerly-panicking
411    /// inputs; `path_lone_quote_returns_err_not_panic` asserts the graceful rejection,
412    /// and the fuzz guards below tolerate them for free.
413    const KNOWN_PANIC_INPUTS: &[&str] = &["path(\")", "path( \" )"];
414
415    fn is_known_panic(input: &str) -> bool {
416        KNOWN_PANIC_INPUTS.contains(&input)
417    }
418
419    /// Every input the shape-function parsers must survive: malformed, huge,
420    /// boundary-numeric and non-ASCII. Includes the known-panic family above so
421    /// the corpus stays honest; callers filter it explicitly.
422    fn nasty_corpus() -> Vec<String> {
423        let mut corpus: Vec<String> = [
424            // empty / whitespace (incl. U+00A0, which `str::trim` also strips)
425            "",
426            " ",
427            "      ",
428            "\t",
429            "\n",
430            "\r\n",
431            "\t \n ",
432            "\u{a0}",
433            // keyword handling
434            "none",
435            " none ",
436            "NONE",
437            "None",
438            "nonee",
439            "none none",
440            "none;",
441            // bare punctuation / unbalanced parens
442            "(",
443            ")",
444            "()",
445            ")(",
446            "((",
447            "))",
448            "(()",
449            "())",
450            "!@#$%^&*",
451            ";;;;",
452            ",,,,",
453            "\0",
454            "\0\0(\0)\0",
455            "\u{7f}",
456            // structurally broken function calls
457            "circle",
458            "circle(",
459            "circle)",
460            "circle()",
461            "circle( )",
462            "circle(50px",
463            "circle 50px)",
464            "circle(50px))",
465            "((circle(50px)))",
466            "unknown(50px)",
467            "square(1px)",
468            "(50px)",
469            " (50px) ",
470            "circle(;)",
471            "circle(,)",
472            // leading / trailing junk
473            "circle(50px);garbage",
474            "circle(50px)garbage",
475            "junk circle(50px)",
476            "circle(50px) circle(50px)",
477            // boundary numbers
478            "circle(0)",
479            "circle(-0)",
480            "circle(0px)",
481            "circle(-0px)",
482            "circle(-50px)",
483            "circle(50)",
484            "circle(50%)",
485            "circle(NaN)",
486            "circle(nan)",
487            "circle(inf)",
488            "circle(-inf)",
489            "circle(infpx)",
490            "circle(NaNpx)",
491            "circle(1e400px)",
492            "circle(-1e400)",
493            "circle(9223372036854775807px)",
494            "circle(-9223372036854775808)",
495            "circle(0x10px)",
496            "circle(1_000px)",
497            "circle(+5px)",
498            "circle(.5px)",
499            "circle(5.px)",
500            // arity edges
501            "circle(50px at)",
502            "circle(50px at 1px)",
503            "circle(50px at 1px 2px 3px)",
504            "circle(50px AT 1px 2px)",
505            "ellipse()",
506            "ellipse(1px)",
507            "ellipse(1px 2px)",
508            "ellipse(1px 2px at 3px 4px)",
509            "ellipse(a b)",
510            "polygon()",
511            "polygon(,)",
512            "polygon(0 0)",
513            "polygon(0 0, 1 1)",
514            "polygon(0 0, 1 1, 2 2)",
515            "polygon(0 0, 1 1, 2 2,)",
516            "polygon(nonzero,)",
517            "polygon(nonzero, 0 0, 1 1, 2 2)",
518            "polygon(evenodd,0 0,1 1,2 2)",
519            "polygon(nonzero 0 0, 1 1, 2 2)",
520            "polygon(0, 1, 2)",
521            "polygon(x y, x y, x y)",
522            "inset()",
523            "inset( )",
524            "inset(10px)",
525            "inset(1px 2px 3px 4px 5px)",
526            "inset(round)",
527            "inset(round 5px)",
528            "inset(10px round)",
529            "inset(10px round 5px)",
530            "inset(10px round 5px 6px)",
531            "inset(roundround)",
532            "path()",
533            "path(abc)",
534            "path(\"\")",
535            "path(\"M 0 0 Z\")",
536            "path(\"\"\")",
537            "path(\"🙂\")",
538            // non-ASCII: `parse_function` slices on the byte offsets of '(' and
539            // ')', so every multibyte input here is a char-boundary probe.
540            "🙂",
541            "🙂(1px)",
542            "circle(🙂)",
543            "circle(🙂px)",
544            "circle(1px at 🙂 🙂)",
545            "cïrcle(1px)",
546            "e\u{0301}(1px)",
547            "\u{202e}circle(1px)",
548            "circle(١٢٣px)",
549            "\u{1f600}\u{1f600}(\u{1f600})",
550        ]
551        .iter()
552        .map(|s| (*s).to_string())
553        .collect();
554
555        for panicking in KNOWN_PANIC_INPUTS {
556            corpus.push((*panicking).to_string());
557        }
558
559        // Pathological sizes: must terminate, must not overflow the stack.
560        corpus.push("a".repeat(1_000_000));
561        corpus.push("(".repeat(100_000));
562        corpus.push(format!("circle({}px)", "9".repeat(10_000)));
563        corpus.push("circle(".repeat(10_000) + &")".repeat(10_000));
564        corpus.push(format!("polygon({}1px 1px)", "1px 1px, ".repeat(10_000)));
565        corpus.push(format!("path(\"{}\")", "M 0 0 ".repeat(10_000)));
566
567        corpus
568    }
569
570    fn hash_of<T: Hash>(value: &T) -> u64 {
571        use core::hash::Hasher;
572        use std::collections::hash_map::DefaultHasher;
573
574        let mut hasher = DefaultHasher::new();
575        value.hash(&mut hasher);
576        hasher.finish()
577    }
578
579    fn clip_shape(input: &str) -> CssShape {
580        match parse_clip_path(input) {
581            Ok(ClipPath::Shape(shape)) => shape,
582            other => panic!("expected a shape for {input:?}, got {other:?}"),
583        }
584    }
585
586    // ---------------------------------------------------------------------
587    // KNOWN BUGS — characterization tests (see module docs)
588    // ---------------------------------------------------------------------
589
590    /// KNOWN BUG (`shape_parser::parse_path`): a lone `"` as the argument passes
591    /// *both* the `starts_with('"')` and `ends_with('"')` guards, so
592    /// `&args[1..args.len() - 1]` slices `[1..0]` and panics with
593    /// "slice index starts at 1 but ends at 0".
594    ///
595    /// It is reachable from all three public shape parsers, from untrusted CSS:
596    /// `clip-path: path(")`. The correct result is
597    /// `Err(ShapeParseError::InvalidSyntax(_))`.
598    ///
599    /// FIXED: `parse_path` now requires `args.len() >= 2` before slicing, so a lone
600    /// `"` argument returns `Err(InvalidSyntax)` instead of panicking on the reversed
601    /// `[1..0]` slice. These inputs are reachable from untrusted CSS (`clip-path:
602    /// path(")`), so the graceful-rejection guarantee matters.
603    #[test]
604    fn path_lone_quote_returns_err_not_panic() {
605        for input in KNOWN_PANIC_INPUTS {
606            let outcome =
607                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parse_clip_path(input)));
608            match outcome {
609                Ok(res) => assert!(res.is_err(), "{input:?} must be rejected, got {res:?}"),
610                Err(_) => {
611                    panic!("parse_clip_path({input:?}) still panics — the slice bug regressed")
612                }
613            }
614        }
615    }
616
617    /// KNOWN BUG (`props::basic::pixel::parse_pixel_value`): the metric table is
618    /// scanned in order and tries `("in", In)` *before* `("vmin", Vmin)`. Since
619    /// "vmin" ends with "in", `10vmin` strips to `10vm`, which fails to parse as
620    /// an f32 — so the valid CSS unit `vmin` is rejected outright. `vmax`, `vw`
621    /// and `vh` are unaffected (no earlier metric is a suffix of them).
622    ///
623    /// This is not shape-specific: every property that routes through
624    /// `parse_pixel_value` (width, margin, padding, …) rejects `vmin` too.
625    ///
626    /// WHEN pixel.rs IS FIXED (match longest metric first, or move vmax/vmin
627    /// ahead of "in"), this test fails — replace it with:
628    ///     `assert_eq!(parse_shape_margin("10vmin").unwrap().inner.metric`, `SizeMetric::Vmin`);
629    #[test]
630    fn known_bug_vmin_unit_is_rejected_by_metric_table_order() {
631        // FIXED (as this pin's own message instructed): "10vmin" now parses to Vmin.
632        assert_eq!(
633            parse_shape_margin("10vmin").unwrap().inner.metric,
634            SizeMetric::Vmin
635        );
636
637        // The sibling viewport units do work, which is what makes the bug easy
638        // to miss: only the unit that *ends in an earlier metric* is broken.
639        assert_eq!(
640            parse_shape_margin("10vmax").unwrap().inner.metric,
641            SizeMetric::Vmax
642        );
643        assert_eq!(
644            parse_shape_margin("10vw").unwrap().inner.metric,
645            SizeMetric::Vw
646        );
647        assert_eq!(
648            parse_shape_margin("10vh").unwrap().inner.metric,
649            SizeMetric::Vh
650        );
651    }
652
653    /// A NaN f32 in a shape used to break the `Eq`/`Ord` contracts: the property
654    /// enums derived `PartialEq` (raw compare, NaN != NaN) while hand-writing
655    /// `Ord`/`Hash` as NaN-Equal (`to_bits`), so `a == a` was false yet `cmp` said
656    /// `Equal`. Fixed: `PartialEq` is now hand-written to match `Ord`, so a
657    /// preserved NaN length stays reflexive and consistent with Hash/Ord.
658    #[test]
659    fn nan_shape_is_reflexive_and_consistent_across_eq_ord_hash() {
660        let a = parse_clip_path("circle(NaN)").expect("NaN is a preserved length");
661        let b = parse_clip_path("circle(NaN)").expect("NaN is a preserved length");
662        assert_eq!(a, a, "Eq must be reflexive for a NaN shape");
663        assert_eq!(a, b);
664        assert_eq!(a.cmp(&b), Ordering::Equal);
665        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
666        assert_eq!(hash_of(&a), hash_of(&b));
667    }
668
669    /// Contrast with the bug above: `ShapeMargin` / `ShapeImageThreshold` store
670    /// their numbers as `FloatValue` (an isize-encoded fixed-point), and the
671    /// f32 -> isize cast maps NaN to 0. So no NaN can survive into these two
672    /// types and their derived `Eq` really is reflexive.
673    #[test]
674    fn floatvalue_encoding_makes_margin_and_threshold_nan_free() {
675        let threshold = parse_shape_image_threshold("NaN").expect("f32 parses NaN");
676        assert!(threshold.inner.get().is_finite());
677        assert_eq!(threshold.inner.get(), 0.0);
678        assert_eq!(threshold, threshold);
679        assert_eq!(hash_of(&threshold), hash_of(&threshold));
680
681        // "NaNpx" is *accepted* (leniency worth tightening) but cannot produce a
682        // NaN PixelValue. Written to also pass once the parser rejects it.
683        if let Ok(margin) = parse_shape_margin("NaNpx") {
684            assert!(
685                margin.inner.number.get().is_finite(),
686                "NaN leaked into a PixelValue"
687            );
688            assert_eq!(margin.inner.number.get(), 0.0);
689            assert_eq!(margin, margin);
690        } else { /* rejecting "NaNpx" outright would be more correct */
691        }
692    }
693
694    // ---------------------------------------------------------------------
695    // Panic / hang safety across the whole corpus
696    // ---------------------------------------------------------------------
697
698    /// The headline invariant: no input may panic any shape-function parser.
699    ///
700    /// Written as "the set of panicking inputs is a subset of the known-bug set",
701    /// so it keeps passing (and keeps guarding) after `parse_path` is fixed.
702    #[test]
703    fn shape_parsers_never_panic_on_hostile_input() {
704        let mut unexpected: Vec<String> = Vec::new();
705
706        for input in nasty_corpus() {
707            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
708                let _ = parse_shape_outside(&input);
709                let _ = parse_shape_inside(&input);
710                let _ = parse_clip_path(&input);
711            }));
712
713            if outcome.is_err() && !is_known_panic(&input) {
714                let preview: String = input.chars().take(48).collect();
715                unexpected.push(preview);
716            }
717        }
718
719        assert!(
720            unexpected.is_empty(),
721            "shape parsers panicked on input(s) outside the known-bug set: \
722             {unexpected:?}"
723        );
724    }
725
726    /// Same invariant for the two numeric parsers — these have no known panics,
727    /// so the bar is absolute.
728    #[test]
729    fn numeric_parsers_never_panic_on_hostile_input() {
730        for input in nasty_corpus() {
731            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
732                let _ = parse_shape_margin(&input);
733                let _ = parse_shape_image_threshold(&input);
734            }));
735            assert!(
736                outcome.is_ok(),
737                "numeric parser panicked on {:?}",
738                input.chars().take(48).collect::<String>()
739            );
740        }
741    }
742
743    /// All three properties delegate to the same `parse_shape`, so they must
744    /// accept exactly the same language and produce the same shape. Compared via
745    /// `Ord` rather than `==` because NaN shapes are not self-equal (see
746    /// `known_bug_nan_shape_breaks_eq_ord_consistency`).
747    #[test]
748    fn the_three_shape_properties_agree_on_every_input() {
749        for input in nasty_corpus() {
750            if is_known_panic(&input) {
751                continue;
752            }
753
754            let outside = parse_shape_outside(&input);
755            let inside = parse_shape_inside(&input);
756            let clip = parse_clip_path(&input);
757
758            assert_eq!(
759                outside.is_ok(),
760                inside.is_ok(),
761                "shape-outside and shape-inside disagree on {input:?}"
762            );
763            assert_eq!(
764                outside.is_ok(),
765                clip.is_ok(),
766                "shape-outside and clip-path disagree on {input:?}"
767            );
768
769            if let (Ok(ShapeOutside::Shape(a)), Ok(ShapeInside::Shape(b)), Ok(ClipPath::Shape(c))) =
770                (&outside, &inside, &clip)
771            {
772                assert_eq!(a.cmp(b), Ordering::Equal, "different shape for {input:?}");
773                assert_eq!(a.cmp(c), Ordering::Equal, "different shape for {input:?}");
774            }
775        }
776    }
777
778    /// A million-character input, a 10 000-deep paren nest and a 10 000-point
779    /// polygon must all terminate. `parse_shape` is iterative, so nesting must
780    /// not grow the stack; if this ever hangs or overflows, the test never
781    /// returns and the suite times out rather than passing silently.
782    #[test]
783    fn pathological_sizes_terminate_without_stack_overflow() {
784        let million = "a".repeat(1_000_000);
785        assert!(matches!(
786            parse_clip_path(&million),
787            Err(ShapeParseError::InvalidSyntax(_))
788        ));
789
790        let nested = "circle(".repeat(10_000) + &")".repeat(10_000);
791        assert!(parse_clip_path(&nested).is_err());
792
793        let unclosed = "(".repeat(100_000);
794        assert!(matches!(
795            parse_clip_path(&unclosed),
796            Err(ShapeParseError::InvalidSyntax(_))
797        ));
798
799        let big_polygon = format!("polygon({}1px 1px)", "1px 1px, ".repeat(10_000));
800        match clip_shape(&big_polygon) {
801            CssShape::Polygon(ShapePolygon { points }) => {
802                assert_eq!(points.as_ref().len(), 10_001);
803            }
804            other => panic!("expected Polygon, got {other:?}"),
805        }
806
807        // 10 000 digits overflow f32 to infinity rather than erroring — the
808        // shape keeps a raw f32, so this is where a non-finite radius gets in.
809        let huge_radius = format!("circle({}px)", "9".repeat(10_000));
810        match clip_shape(&huge_radius) {
811            CssShape::Circle(ShapeCircle { radius, .. }) => {
812                assert!(radius.is_infinite() && radius.is_sign_positive());
813            }
814            other => panic!("expected Circle, got {other:?}"),
815        }
816    }
817
818    // ---------------------------------------------------------------------
819    // parse_shape_outside / parse_shape_inside / parse_clip_path
820    // ---------------------------------------------------------------------
821
822    #[test]
823    fn empty_and_whitespace_only_input_is_empty_input_error() {
824        for input in ["", " ", "      ", "\t", "\n", "\r\n", "\t \n ", "\u{a0}"] {
825            assert_eq!(
826                parse_shape_outside(input),
827                Err(ShapeParseError::EmptyInput),
828                "shape-outside {input:?}"
829            );
830            assert_eq!(
831                parse_shape_inside(input),
832                Err(ShapeParseError::EmptyInput),
833                "shape-inside {input:?}"
834            );
835            assert_eq!(
836                parse_clip_path(input),
837                Err(ShapeParseError::EmptyInput),
838                "clip-path {input:?}"
839            );
840        }
841    }
842
843    /// Positive control, plus the one piece of trimming the parsers do promise.
844    #[test]
845    fn none_keyword_parses_and_is_trimmed() {
846        for input in ["none", " none ", "\tnone\n", "  none\r\n"] {
847            assert_eq!(parse_shape_outside(input), Ok(ShapeOutside::None));
848            assert_eq!(parse_shape_inside(input), Ok(ShapeInside::None));
849            assert_eq!(parse_clip_path(input), Ok(ClipPath::None));
850        }
851    }
852
853    /// `none` is matched case-sensitively, which is not CSS-conformant (CSS
854    /// keywords are ASCII case-insensitive). Asserted as an invariant that holds
855    /// either way — uppercase must never silently become a *shape*.
856    #[test]
857    fn uppercase_none_never_yields_a_shape() {
858        for input in ["NONE", "None", "nOnE"] {
859            match parse_clip_path(input) {
860                Ok(ClipPath::None) | Err(_) => {}
861                Ok(other) => panic!("{input:?} parsed as a shape: {other:?}"),
862            }
863        }
864        // Current behaviour: rejected as an unparseable function.
865        assert!(parse_clip_path("NONE").is_err());
866    }
867
868    #[test]
869    fn garbage_and_broken_parens_are_rejected() {
870        // No '(' at all.
871        for input in ["!@#$%^&*", ";;;;", ",,,,", "circle", "\u{7f}", "🙂"] {
872            assert!(
873                matches!(
874                    parse_clip_path(input),
875                    Err(ShapeParseError::InvalidSyntax(_))
876                ),
877                "expected InvalidSyntax for {input:?}"
878            );
879        }
880
881        // '(' but no ')'.
882        assert!(matches!(
883            parse_clip_path("circle(50px"),
884            Err(ShapeParseError::InvalidSyntax(_))
885        ));
886        // ')' before '('.
887        assert!(matches!(
888            parse_clip_path(")("),
889            Err(ShapeParseError::InvalidSyntax(_))
890        ));
891        // Empty function name.
892        assert!(matches!(
893            parse_clip_path("()"),
894            Err(ShapeParseError::UnknownFunction(_))
895        ));
896        // Unknown function names carry the offending name.
897        assert_eq!(
898            parse_clip_path("square(1px)"),
899            Err(ShapeParseError::UnknownFunction("square".to_string()))
900        );
901        assert_eq!(
902            parse_clip_path("junk circle(50px)"),
903            Err(ShapeParseError::UnknownFunction("junk circle".to_string()))
904        );
905    }
906
907    /// Trailing junk after the closing paren is currently *ignored*:
908    /// `parse_function` takes `rfind(')')` and never checks that the input ends
909    /// there, so `circle(50px);garbage` parses as a plain circle. That is a
910    /// leniency bug (a declaration with trailing garbage should be dropped), but
911    /// it is not a memory-safety issue.
912    ///
913    /// Asserted as the invariant that must hold either way: the parser may
914    /// reject the input, but it must never return a *different* shape than the
915    /// prefix describes.
916    #[test]
917    fn trailing_junk_is_ignored_but_never_changes_the_shape() {
918        for input in [
919            "circle(50px);garbage",
920            "circle(50px)garbage",
921            "circle(50px)🙂",
922        ] {
923            match parse_clip_path(input) {
924                Ok(ClipPath::Shape(CssShape::Circle(ShapeCircle { center, radius }))) => {
925                    assert_eq!(radius, 50.0, "{input:?}");
926                    assert_eq!(center.x, 0.0);
927                    assert_eq!(center.y, 0.0);
928                }
929                Err(_) => { /* rejecting trailing junk would be more correct */ }
930                other => panic!("{input:?} produced an unexpected value: {other:?}"),
931            }
932        }
933    }
934
935    /// `parse_function` slices `input` at the *byte* offsets of '(' and ')'.
936    /// Those are ASCII, so they can never land inside a multibyte sequence — but
937    /// only if nothing else slices. These probe that.
938    #[test]
939    fn multibyte_input_does_not_panic_and_is_rejected() {
940        for input in [
941            "🙂(1px)",
942            "circle(🙂)",
943            "circle(🙂px)",
944            "circle(1px at 🙂 🙂)",
945            "cïrcle(1px)",
946            "e\u{0301}(1px)",
947            "\u{202e}circle(1px)",
948            "circle(١٢٣px)",
949            "\u{1f600}\u{1f600}(\u{1f600})",
950        ] {
951            assert!(
952                parse_clip_path(input).is_err(),
953                "expected Err for {input:?}"
954            );
955        }
956
957        // Multibyte *inside* a quoted path is data, and is preserved verbatim.
958        match clip_shape("path(\"🙂\")") {
959            CssShape::Path(ShapePath { data }) => assert_eq!(data.as_str(), "🙂"),
960            other => panic!("expected Path, got {other:?}"),
961        }
962    }
963
964    #[test]
965    fn circle_boundary_numbers() {
966        // Unitless and `%` are both accepted; `%` is silently treated as a raw
967        // number (parse_length has a TODO — it needs the container size).
968        for input in ["circle(50px)", "circle(50)", "circle(50%)"] {
969            match clip_shape(input) {
970                CssShape::Circle(ShapeCircle { radius, .. }) => assert_eq!(radius, 50.0),
971                other => panic!("expected Circle, got {other:?}"),
972            }
973        }
974
975        // Zero, signed zero, and negative radii are all accepted. A negative
976        // radius is invalid per CSS Shapes; it is stored as-is.
977        for (input, expected) in [
978            ("circle(0px)", 0.0_f32),
979            ("circle(-0px)", -0.0_f32),
980            ("circle(-50px)", -50.0_f32),
981            ("circle(+5px)", 5.0_f32),
982            ("circle(.5px)", 0.5_f32),
983            ("circle(5.px)", 5.0_f32),
984        ] {
985            match clip_shape(input) {
986                CssShape::Circle(ShapeCircle { radius, .. }) => {
987                    assert_eq!(radius, expected, "{input:?}");
988                }
989                other => panic!("expected Circle, got {other:?}"),
990            }
991        }
992
993        // f32 overflow saturates to infinity instead of erroring.
994        for input in ["circle(1e400px)", "circle(inf)", "circle(infpx)"] {
995            match clip_shape(input) {
996                CssShape::Circle(ShapeCircle { radius, .. }) => {
997                    assert!(radius.is_infinite(), "{input:?} -> {radius}");
998                }
999                other => panic!("expected Circle, got {other:?}"),
1000            }
1001        }
1002
1003        // i64::MAX survives as an f32 approximation, no overflow panic.
1004        match clip_shape("circle(9223372036854775807px)") {
1005            CssShape::Circle(ShapeCircle { radius, .. }) => {
1006                assert!(radius.is_finite() && radius > 9.0e18);
1007            }
1008            other => panic!("expected Circle, got {other:?}"),
1009        }
1010
1011        // Rust-only / C-only numeric literals are NOT valid CSS numbers.
1012        for input in ["circle(0x10px)", "circle(1_000px)"] {
1013            assert!(
1014                matches!(
1015                    parse_clip_path(input),
1016                    Err(ShapeParseError::InvalidNumber(_))
1017                ),
1018                "expected InvalidNumber for {input:?}"
1019            );
1020        }
1021    }
1022
1023    /// `circle()` needs 4 parts *and* `parts[1] == "at"` before it reads a
1024    /// center; anything else silently falls back to the origin rather than
1025    /// erroring. Pin that down — a partial `at` clause is not a parse error.
1026    #[test]
1027    fn circle_at_clause_arity_falls_back_to_origin() {
1028        for input in [
1029            "circle(50px at)",
1030            "circle(50px at 1px)",
1031            "circle(50px AT 1px 2px)",
1032        ] {
1033            match clip_shape(input) {
1034                CssShape::Circle(ShapeCircle { center, radius }) => {
1035                    assert_eq!(radius, 50.0);
1036                    assert_eq!((center.x, center.y), (0.0, 0.0), "{input:?}");
1037                }
1038                other => panic!("expected Circle, got {other:?}"),
1039            }
1040        }
1041
1042        // A complete `at` clause is honoured; extra trailing parts are ignored.
1043        for input in ["circle(50px at 1px 2px)", "circle(50px at 1px 2px 3px)"] {
1044            match clip_shape(input) {
1045                CssShape::Circle(ShapeCircle { center, radius }) => {
1046                    assert_eq!(radius, 50.0);
1047                    assert_eq!((center.x, center.y), (1.0, 2.0), "{input:?}");
1048                }
1049                other => panic!("expected Circle, got {other:?}"),
1050            }
1051        }
1052
1053        assert!(matches!(
1054            parse_clip_path("circle()"),
1055            Err(ShapeParseError::MissingParameter(_))
1056        ));
1057    }
1058
1059    #[test]
1060    fn ellipse_requires_two_radii() {
1061        for input in ["ellipse()", "ellipse(1px)"] {
1062            assert!(
1063                matches!(
1064                    parse_clip_path(input),
1065                    Err(ShapeParseError::MissingParameter(_))
1066                ),
1067                "expected MissingParameter for {input:?}"
1068            );
1069        }
1070
1071        match clip_shape("ellipse(1px 2px)") {
1072            CssShape::Ellipse(ShapeEllipse {
1073                center,
1074                radius_x,
1075                radius_y,
1076            }) => {
1077                assert_eq!((radius_x, radius_y), (1.0, 2.0));
1078                assert_eq!((center.x, center.y), (0.0, 0.0));
1079            }
1080            other => panic!("expected Ellipse, got {other:?}"),
1081        }
1082
1083        match clip_shape("ellipse(1px 2px at 3px 4px)") {
1084            CssShape::Ellipse(ShapeEllipse {
1085                center,
1086                radius_x,
1087                radius_y,
1088            }) => {
1089                assert_eq!((radius_x, radius_y), (1.0, 2.0));
1090                assert_eq!((center.x, center.y), (3.0, 4.0));
1091            }
1092            other => panic!("expected Ellipse, got {other:?}"),
1093        }
1094
1095        assert!(matches!(
1096            parse_clip_path("ellipse(a b)"),
1097            Err(ShapeParseError::InvalidNumber(_))
1098        ));
1099    }
1100
1101    #[test]
1102    fn polygon_needs_three_complete_points() {
1103        // Fewer than 3 points, empty args, and a trailing comma all fail.
1104        for input in [
1105            "polygon()",
1106            "polygon(,)",
1107            "polygon(0 0)",
1108            "polygon(0 0, 1 1)",
1109            "polygon(0 0, 1 1, 2 2,)",
1110            "polygon(0, 1, 2)",
1111            "polygon(nonzero,)",
1112        ] {
1113            assert!(
1114                parse_clip_path(input).is_err(),
1115                "expected Err for {input:?}"
1116            );
1117        }
1118
1119        match clip_shape("polygon(0 0, 1 1, 2 2)") {
1120            CssShape::Polygon(ShapePolygon { points }) => {
1121                assert_eq!(points.as_ref().len(), 3);
1122                assert_eq!((points.as_ref()[2].x, points.as_ref()[2].y), (2.0, 2.0));
1123            }
1124            other => panic!("expected Polygon, got {other:?}"),
1125        }
1126
1127        // The optional fill-rule prefix is accepted (and ignored) only when it
1128        // is immediately followed by a comma.
1129        for input in [
1130            "polygon(nonzero, 0 0, 1 1, 2 2)",
1131            "polygon(evenodd,0 0,1 1,2 2)",
1132        ] {
1133            match clip_shape(input) {
1134                CssShape::Polygon(ShapePolygon { points }) => {
1135                    assert_eq!(points.as_ref().len(), 3, "{input:?}");
1136                }
1137                other => panic!("expected Polygon, got {other:?}"),
1138            }
1139        }
1140        assert!(matches!(
1141            parse_clip_path("polygon(nonzero 0 0, 1 1, 2 2)"),
1142            Err(ShapeParseError::InvalidNumber(_))
1143        ));
1144        assert!(matches!(
1145            parse_clip_path("polygon(x y, x y, x y)"),
1146            Err(ShapeParseError::InvalidNumber(_))
1147        ));
1148    }
1149
1150    #[test]
1151    fn inset_shorthand_and_round_keyword() {
1152        // 1/2/3/4-value shorthand, same rules as margin/padding.
1153        let cases: [(&str, [f32; 4]); 4] = [
1154            ("inset(10px)", [10.0, 10.0, 10.0, 10.0]),
1155            ("inset(1px 2px)", [1.0, 2.0, 1.0, 2.0]),
1156            ("inset(1px 2px 3px)", [1.0, 2.0, 3.0, 2.0]),
1157            ("inset(1px 2px 3px 4px)", [1.0, 2.0, 3.0, 4.0]),
1158        ];
1159        for (input, [top, right, bottom, left]) in cases {
1160            match clip_shape(input) {
1161                CssShape::Inset(ShapeInset {
1162                    inset_top,
1163                    inset_right,
1164                    inset_bottom,
1165                    inset_left,
1166                    border_radius,
1167                }) => {
1168                    assert_eq!(
1169                        [inset_top, inset_right, inset_bottom, inset_left],
1170                        [top, right, bottom, left],
1171                        "{input:?}"
1172                    );
1173                    assert!(matches!(border_radius, OptionF32::None));
1174                }
1175                other => panic!("expected Inset, got {other:?}"),
1176            }
1177        }
1178
1179        // 5 values is rejected; no values is rejected.
1180        assert!(matches!(
1181            parse_clip_path("inset(1px 2px 3px 4px 5px)"),
1182            Err(ShapeParseError::InvalidSyntax(_))
1183        ));
1184        assert!(matches!(
1185            parse_clip_path("inset()"),
1186            Err(ShapeParseError::MissingParameter(_))
1187        ));
1188        assert!(matches!(
1189            parse_clip_path("inset( )"),
1190            Err(ShapeParseError::MissingParameter(_))
1191        ));
1192
1193        // `round` with a missing / unparseable radius errors rather than
1194        // panicking on the `args[round_pos + 5..]` slice.
1195        for input in [
1196            "inset(round)",
1197            "inset(10px round)",
1198            "inset(roundround)",
1199            "inset(10px round 5px 6px)",
1200        ] {
1201            assert!(
1202                matches!(
1203                    parse_clip_path(input),
1204                    Err(ShapeParseError::InvalidNumber(_))
1205                ),
1206                "expected InvalidNumber for {input:?}"
1207            );
1208        }
1209
1210        match clip_shape("inset(10px round 5px)") {
1211            CssShape::Inset(ShapeInset { border_radius, .. }) => {
1212                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
1213            }
1214            other => panic!("expected Inset, got {other:?}"),
1215        }
1216    }
1217
1218    #[test]
1219    fn path_data_must_be_quoted_and_is_stored_verbatim() {
1220        // Unquoted / half-quoted path data is rejected.
1221        for input in ["path()", "path(abc)", "path(\"abc)", "path(abc\")"] {
1222            assert!(
1223                matches!(
1224                    parse_clip_path(input),
1225                    Err(ShapeParseError::InvalidSyntax(_))
1226                ),
1227                "expected InvalidSyntax for {input:?}"
1228            );
1229        }
1230
1231        // An empty quoted path is valid and yields empty data (this is the
1232        // len == 2 neighbour of the len == 1 panic in the known-bug test).
1233        match clip_shape("path(\"\")") {
1234            CssShape::Path(ShapePath { data }) => assert_eq!(data.as_str(), ""),
1235            other => panic!("expected Path, got {other:?}"),
1236        }
1237
1238        // Path data is never interpreted, so it can contain anything — including
1239        // the parens that `parse_function` scans for, thanks to rfind(')').
1240        match clip_shape("path(\"M 0 0 (L) 1 1 Z\")") {
1241            CssShape::Path(ShapePath { data }) => {
1242                assert_eq!(data.as_str(), "M 0 0 (L) 1 1 Z");
1243            }
1244            other => panic!("expected Path, got {other:?}"),
1245        }
1246    }
1247
1248    // ---------------------------------------------------------------------
1249    // Round-trip: print_as_css_value -> parse -> identical value
1250    // ---------------------------------------------------------------------
1251
1252    #[test]
1253    fn shape_properties_round_trip_through_their_css_representation() {
1254        let inputs = [
1255            "none",
1256            "circle(50px)",
1257            "circle(50px at 10px 20px)",
1258            "circle(-1px at -2px -3px)",
1259            "ellipse(1px 2px)",
1260            "ellipse(1px 2px at 3px 4px)",
1261            "polygon(0px 0px, 100px 0px, 100px 100px)",
1262            "polygon(0px 0px, 1px 1px, 2px 2px, 3px 3px, 4px 4px)",
1263            "inset(1px 2px 3px 4px)",
1264            "inset(10px round 5px)",
1265            "path(\"M 0 0 L 1 1 Z\")",
1266        ];
1267
1268        for input in inputs {
1269            let outside = parse_shape_outside(input).expect(input);
1270            let printed = outside.print_as_css_value();
1271            assert_eq!(
1272                parse_shape_outside(&printed).as_ref(),
1273                Ok(&outside),
1274                "shape-outside round-trip changed the value: {input:?} -> {printed:?}"
1275            );
1276            // Printing must also be idempotent, not just re-parseable.
1277            assert_eq!(
1278                parse_shape_outside(&printed)
1279                    .expect(input)
1280                    .print_as_css_value(),
1281                printed
1282            );
1283
1284            let inside = parse_shape_inside(input).expect(input);
1285            let printed = inside.print_as_css_value();
1286            assert_eq!(
1287                parse_shape_inside(&printed).as_ref(),
1288                Ok(&inside),
1289                "{input:?}"
1290            );
1291
1292            let clip = parse_clip_path(input).expect(input);
1293            let printed = clip.print_as_css_value();
1294            assert_eq!(parse_clip_path(&printed).as_ref(), Ok(&clip), "{input:?}");
1295        }
1296    }
1297
1298    #[test]
1299    fn none_prints_as_the_none_keyword() {
1300        assert_eq!(ShapeOutside::None.print_as_css_value(), "none");
1301        assert_eq!(ShapeInside::None.print_as_css_value(), "none");
1302        assert_eq!(ClipPath::None.print_as_css_value(), "none");
1303    }
1304
1305    #[test]
1306    fn margin_and_threshold_round_trip() {
1307        for input in ["0px", "10px", "-5px", "1.5em", "50%", "2rem", "12pt", "1in"] {
1308            let margin = parse_shape_margin(input).expect(input);
1309            let printed = margin.print_as_css_value();
1310            assert_eq!(
1311                parse_shape_margin(&printed).expect(&printed),
1312                margin,
1313                "shape-margin round-trip changed the value: {input:?} -> {printed:?}"
1314            );
1315        }
1316
1317        for input in ["0", "0.5", "1", "0.001", "0.999"] {
1318            let threshold = parse_shape_image_threshold(input).expect(input);
1319            let printed = threshold.print_as_css_value();
1320            assert_eq!(
1321                parse_shape_image_threshold(&printed).expect(&printed),
1322                threshold,
1323                "shape-image-threshold round-trip changed the value: {input:?} -> {printed:?}"
1324            );
1325        }
1326    }
1327
1328    // ---------------------------------------------------------------------
1329    // parse_shape_margin
1330    // ---------------------------------------------------------------------
1331
1332    #[test]
1333    fn margin_empty_and_whitespace_only_is_empty_string_error() {
1334        for input in ["", " ", "     ", "\t", "\n", "\r\n", "\u{a0}"] {
1335            assert!(
1336                matches!(
1337                    parse_shape_margin(input),
1338                    Err(CssPixelValueParseError::EmptyString)
1339                ),
1340                "expected EmptyString for {input:?}"
1341            );
1342        }
1343    }
1344
1345    #[test]
1346    fn margin_valid_units_map_to_the_right_metric() {
1347        // NOTE: `vmin` is missing here on purpose — it is broken. See
1348        // `known_bug_vmin_unit_is_rejected_by_metric_table_order`.
1349        let cases = [
1350            ("10px", SizeMetric::Px),
1351            ("10em", SizeMetric::Em),
1352            ("10rem", SizeMetric::Rem),
1353            ("10pt", SizeMetric::Pt),
1354            ("10in", SizeMetric::In),
1355            ("10cm", SizeMetric::Cm),
1356            ("10mm", SizeMetric::Mm),
1357            ("10%", SizeMetric::Percent),
1358            ("10vw", SizeMetric::Vw),
1359            ("10vh", SizeMetric::Vh),
1360            ("10vmax", SizeMetric::Vmax),
1361            // Unitless numbers are accepted and default to px.
1362            ("10", SizeMetric::Px),
1363        ];
1364
1365        for (input, metric) in cases {
1366            let margin = parse_shape_margin(input).expect(input);
1367            assert_eq!(margin.inner.metric, metric, "{input:?}");
1368            assert_eq!(margin.inner.number.get(), 10.0, "{input:?}");
1369        }
1370
1371        // Whitespace around the value and between number and unit is tolerated.
1372        assert_eq!(
1373            parse_shape_margin("  10px  ").expect("padded").inner,
1374            PixelValue::px(10.0)
1375        );
1376        assert_eq!(
1377            parse_shape_margin("10 px").expect("inner space").inner,
1378            PixelValue::px(10.0)
1379        );
1380    }
1381
1382    #[test]
1383    fn margin_rejects_malformed_and_junk_suffixed_values() {
1384        // A unit with no number.
1385        assert!(matches!(
1386            parse_shape_margin("px"),
1387            Err(CssPixelValueParseError::NoValueGiven(_, SizeMetric::Px))
1388        ));
1389        // A number with a bad number part in front of a known unit.
1390        assert!(matches!(
1391            parse_shape_margin("abcpx"),
1392            Err(CssPixelValueParseError::ValueParseErr(_, "abc"))
1393        ));
1394        // Neither a known unit nor a bare number.
1395        for input in [
1396            "10px;garbage",
1397            "garbage",
1398            "10 20px",
1399            "10px 20px",
1400            "10PX",
1401            "10Px",
1402            "10px🙂",
1403            "🙂px",
1404            "!@#$",
1405        ] {
1406            assert!(
1407                parse_shape_margin(input).is_err(),
1408                "expected Err for {input:?}"
1409            );
1410        }
1411    }
1412
1413    /// Unit matching is ASCII-case-sensitive, which is not CSS-conformant.
1414    /// Asserted so it holds either way: uppercase must never yield a *wrong*
1415    /// value, only the right one or an error.
1416    #[test]
1417    fn margin_uppercase_units_never_yield_a_wrong_value() {
1418        for input in ["10PX", "10Px", "10EM", "10%"] {
1419            if let Ok(margin) = parse_shape_margin(input) {
1420                assert_eq!(margin.inner.number.get(), 10.0, "{input:?}")
1421            } else { /* current behaviour for the uppercase forms */
1422            }
1423        }
1424    }
1425
1426    /// The `FloatValue` isize encoding is the only thing standing between a
1427    /// hostile stylesheet and a non-finite length in layout. Nothing that parses
1428    /// may produce a NaN or infinite `PixelValue`.
1429    #[test]
1430    fn margin_never_produces_a_non_finite_value() {
1431        let extremes = [
1432            "0px",
1433            "-0px",
1434            "0",
1435            "-0",
1436            "NaNpx",
1437            "nanpx",
1438            "infpx",
1439            "-infpx",
1440            "inf",
1441            "-inf",
1442            "1e400px",
1443            "-1e400px",
1444            "1e38px",
1445            "-1e38px",
1446            "9223372036854775807px",
1447            "-9223372036854775808px",
1448            "340282350000000000000000000000000000000px",
1449            "0.0000000000000000001px",
1450        ];
1451
1452        for input in extremes {
1453            if let Ok(margin) = parse_shape_margin(input) {
1454                let value = margin.inner.number.get();
1455                assert!(
1456                    value.is_finite(),
1457                    "{input:?} produced a non-finite PixelValue: {value}"
1458                );
1459            }
1460        }
1461
1462        // Saturation, specifically: `1e400` parses to f32::INFINITY (Rust's f32
1463        // FromStr does not error on overflow), and the `f32 as isize` cast in
1464        // FloatValue::new then saturates at the isize bound — which is exactly
1465        // what keeps the infinity from reaching layout.
1466        let saturated = parse_shape_margin("1e400px").expect("f32 overflow parses as inf");
1467        assert_eq!(saturated.inner.number.number(), isize::MAX);
1468        assert!(saturated.inner.number.get().is_finite());
1469
1470        let saturated_neg = parse_shape_margin("-1e400px").expect("parses as -inf");
1471        assert_eq!(saturated_neg.inner.number.number(), isize::MIN);
1472        assert!(saturated_neg.inner.number.get().is_finite());
1473
1474        // NaN saturates to 0 rather than to a bound.
1475        assert_eq!(
1476            parse_shape_margin("NaNpx")
1477                .expect("NaN currently parses")
1478                .inner
1479                .number
1480                .number(),
1481            0
1482        );
1483    }
1484
1485    /// `FloatValue` keeps 3 decimal places and *truncates* toward zero — sizes
1486    /// below 0.001 collapse to exactly 0. Worth pinning: it silently changes
1487    /// authored values.
1488    #[test]
1489    fn margin_quantizes_to_three_decimals_by_truncation() {
1490        assert_eq!(
1491            parse_shape_margin("0.001px")
1492                .expect("0.001")
1493                .inner
1494                .number
1495                .get(),
1496            0.001
1497        );
1498        // 0.0005 does NOT round up to 0.001 — it truncates to 0.
1499        assert_eq!(
1500            parse_shape_margin("0.0005px")
1501                .expect("0.0005")
1502                .inner
1503                .number
1504                .get(),
1505            0.0
1506        );
1507        assert_eq!(
1508            parse_shape_margin("0.0009px")
1509                .expect("0.0009")
1510                .inner
1511                .number
1512                .get(),
1513            0.0
1514        );
1515
1516        let truncated = parse_shape_margin("1.9999px")
1517            .expect("1.9999")
1518            .inner
1519            .number
1520            .get();
1521        assert!(
1522            (truncated - 1.999).abs() < 1.0e-6,
1523            "expected truncation to 1.999, got {truncated}"
1524        );
1525    }
1526
1527    // ---------------------------------------------------------------------
1528    // parse_shape_image_threshold
1529    // ---------------------------------------------------------------------
1530
1531    #[test]
1532    fn threshold_empty_whitespace_and_garbage_are_errors() {
1533        for input in [
1534            "", " ", "   ", "\t\n", "abc", "0.5px", "50%", "1,0", "0.5.5", "🙂", "--1",
1535        ] {
1536            assert!(
1537                parse_shape_image_threshold(input).is_err(),
1538                "expected Err for {input:?}"
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn threshold_parses_and_trims_valid_values() {
1545        assert_eq!(
1546            parse_shape_image_threshold("0").expect("0").inner.get(),
1547            0.0
1548        );
1549        assert_eq!(
1550            parse_shape_image_threshold("1").expect("1").inner.get(),
1551            1.0
1552        );
1553        assert_eq!(
1554            parse_shape_image_threshold("0.5").expect("0.5").inner.get(),
1555            0.5
1556        );
1557        assert_eq!(
1558            parse_shape_image_threshold("  0.5  ")
1559                .expect("padded")
1560                .inner
1561                .get(),
1562            0.5
1563        );
1564        // f32 accepts these spellings; CSS numbers do too.
1565        assert_eq!(
1566            parse_shape_image_threshold("+0.5")
1567                .expect("+0.5")
1568                .inner
1569                .get(),
1570            0.5
1571        );
1572        assert_eq!(
1573            parse_shape_image_threshold("5e-1")
1574                .expect("5e-1")
1575                .inner
1576                .get(),
1577            0.5
1578        );
1579        assert_eq!(
1580            parse_shape_image_threshold(".5").expect(".5").inner.get(),
1581            0.5
1582        );
1583    }
1584
1585    /// The documented contract: the result is clamped to `0.0 ..= 1.0`. Assert it
1586    /// as a hard invariant over every input that parses at all — including the
1587    /// ones that reach `clamp` as infinities.
1588    #[test]
1589    fn threshold_is_always_clamped_to_zero_one_and_finite() {
1590        let extremes = [
1591            "0",
1592            "-0",
1593            "1",
1594            "-1",
1595            "2",
1596            "1.0001",
1597            "-0.0001",
1598            "100",
1599            "1e10",
1600            "-1e10",
1601            "1e38",
1602            "1e400",
1603            "-1e400",
1604            "inf",
1605            "-inf",
1606            "infinity",
1607            "-infinity",
1608            "NaN",
1609            "nan",
1610            "-NaN",
1611            "9223372036854775807",
1612            "-9223372036854775808",
1613            "1e-45",
1614            "-1e-45",
1615            "0.0000001",
1616        ];
1617
1618        for input in extremes {
1619            let Ok(threshold) = parse_shape_image_threshold(input) else {
1620                continue;
1621            };
1622            let value = threshold.inner.get();
1623            assert!(
1624                value.is_finite(),
1625                "{input:?} produced a non-finite threshold: {value}"
1626            );
1627            assert!(
1628                (0.0..=1.0).contains(&value),
1629                "{input:?} escaped the [0, 1] clamp: {value}"
1630            );
1631        }
1632
1633        // Direction of the clamp, specifically.
1634        assert_eq!(
1635            parse_shape_image_threshold("2").expect("2").inner.get(),
1636            1.0
1637        );
1638        assert_eq!(
1639            parse_shape_image_threshold("-1").expect("-1").inner.get(),
1640            0.0
1641        );
1642        assert_eq!(
1643            parse_shape_image_threshold("inf").expect("inf").inner.get(),
1644            1.0
1645        );
1646        assert_eq!(
1647            parse_shape_image_threshold("-inf")
1648                .expect("-inf")
1649                .inner
1650                .get(),
1651            0.0
1652        );
1653        // NaN is neutralised by the isize encoding *before* it reaches clamp
1654        // (`f32 as isize` maps NaN to 0), so it lands on 0.0 rather than
1655        // propagating or panicking.
1656        assert_eq!(
1657            parse_shape_image_threshold("NaN").expect("NaN").inner.get(),
1658            0.0
1659        );
1660    }
1661
1662    /// Same 0.001 truncation as `ShapeMargin`: a threshold below 0.001 becomes a
1663    /// fully transparent 0.
1664    #[test]
1665    fn threshold_quantizes_to_three_decimals() {
1666        assert_eq!(
1667            parse_shape_image_threshold("0.001")
1668                .expect("0.001")
1669                .inner
1670                .get(),
1671            0.001
1672        );
1673        assert_eq!(
1674            parse_shape_image_threshold("0.0005")
1675                .expect("0.0005")
1676                .inner
1677                .get(),
1678            0.0
1679        );
1680        let truncated = parse_shape_image_threshold("0.9999")
1681            .expect("0.9999")
1682            .inner
1683            .get();
1684        assert!(
1685            (truncated - 0.999).abs() < 1.0e-6,
1686            "expected truncation to 0.999, got {truncated}"
1687        );
1688    }
1689
1690    #[test]
1691    fn threshold_survives_a_ten_thousand_digit_number() {
1692        let huge = "9".repeat(10_000);
1693        assert_eq!(
1694            parse_shape_image_threshold(&huge)
1695                .expect("overflows to inf, clamps to 1")
1696                .inner
1697                .get(),
1698            1.0
1699        );
1700
1701        let tiny = format!("0.{}1", "0".repeat(10_000));
1702        assert_eq!(
1703            parse_shape_image_threshold(&tiny)
1704                .expect("underflows to 0")
1705                .inner
1706                .get(),
1707            0.0
1708        );
1709    }
1710
1711    // ---------------------------------------------------------------------
1712    // Type invariants: Default, Ord/PartialOrd agreement, Hash
1713    // ---------------------------------------------------------------------
1714
1715    #[test]
1716    fn defaults_are_none_and_zero() {
1717        assert_eq!(ShapeOutside::default(), ShapeOutside::None);
1718        assert_eq!(ShapeInside::default(), ShapeInside::None);
1719        assert_eq!(ClipPath::default(), ClipPath::None);
1720        assert_eq!(ShapeMargin::default().inner, PixelValue::zero());
1721        assert_eq!(ShapeMargin::default().inner.number.get(), 0.0);
1722        assert_eq!(ShapeImageThreshold::default().inner.get(), 0.0);
1723
1724        // The defaults are exactly what the minimal CSS text parses to.
1725        assert_eq!(parse_clip_path("none").expect("none"), ClipPath::default());
1726        assert_eq!(
1727            parse_shape_margin("0px").expect("0px"),
1728            ShapeMargin::default()
1729        );
1730        assert_eq!(
1731            parse_shape_image_threshold("0").expect("0"),
1732            ShapeImageThreshold::default()
1733        );
1734    }
1735
1736    /// `None` sorts before any shape, and the hand-written `Ord` must agree with
1737    /// the `PartialOrd` that delegates to it.
1738    #[test]
1739    fn none_sorts_before_shape_and_ord_agrees_with_partial_ord() {
1740        let shape_clip = parse_clip_path("circle(1px)").expect("circle");
1741        let shape_out = parse_shape_outside("circle(1px)").expect("circle");
1742        let shape_in = parse_shape_inside("circle(1px)").expect("circle");
1743
1744        assert_eq!(ClipPath::None.cmp(&shape_clip), Ordering::Less);
1745        assert_eq!(shape_clip.cmp(&ClipPath::None), Ordering::Greater);
1746        assert_eq!(ClipPath::None.cmp(&ClipPath::None), Ordering::Equal);
1747        assert_eq!(
1748            ClipPath::None.partial_cmp(&shape_clip),
1749            Some(ClipPath::None.cmp(&shape_clip))
1750        );
1751
1752        assert_eq!(ShapeOutside::None.cmp(&shape_out), Ordering::Less);
1753        assert_eq!(
1754            ShapeOutside::None.partial_cmp(&shape_out),
1755            Some(Ordering::Less)
1756        );
1757
1758        assert_eq!(ShapeInside::None.cmp(&shape_in), Ordering::Less);
1759        assert_eq!(
1760            ShapeInside::None.partial_cmp(&shape_in),
1761            Some(Ordering::Less)
1762        );
1763    }
1764
1765    /// `CssShape`'s `Ord` is hand-written with explicit cross-variant arms whose
1766    /// ORDER encodes the variant ranking. Check it is a strict, antisymmetric
1767    /// total order across all five variants — a merged/reordered arm would show
1768    /// up here as two variants comparing Less in both directions.
1769    #[test]
1770    fn css_shape_variant_ordering_is_antisymmetric() {
1771        let shapes = [
1772            parse_clip_path("circle(1px)").expect("circle"),
1773            parse_clip_path("ellipse(1px 2px)").expect("ellipse"),
1774            parse_clip_path("polygon(0 0, 1 1, 2 2)").expect("polygon"),
1775            parse_clip_path("inset(1px)").expect("inset"),
1776            parse_clip_path("path(\"Z\")").expect("path"),
1777        ];
1778
1779        for (i, a) in shapes.iter().enumerate() {
1780            assert_eq!(a.cmp(a), Ordering::Equal, "variant {i} is not self-equal");
1781
1782            for (j, b) in shapes.iter().enumerate() {
1783                let forward = a.cmp(b);
1784                let backward = b.cmp(a);
1785                assert_eq!(
1786                    forward,
1787                    backward.reverse(),
1788                    "cmp is not antisymmetric for variants {i} and {j}"
1789                );
1790                if i < j {
1791                    assert_eq!(
1792                        forward,
1793                        Ordering::Less,
1794                        "variant {i} should sort before variant {j}"
1795                    );
1796                }
1797            }
1798        }
1799    }
1800
1801    /// Hash must agree with equality for the values that *are* self-equal (i.e.
1802    /// everything except the NaN shapes covered by the known-bug test), and the
1803    /// discriminant must take part so `None` and a shape don't collide.
1804    #[test]
1805    fn hash_agrees_with_equality() {
1806        let a = parse_clip_path("circle(50px at 1px 2px)").expect("circle");
1807        let b = parse_clip_path("circle(50px at 1px 2px)").expect("circle");
1808        assert_eq!(a, b);
1809        assert_eq!(hash_of(&a), hash_of(&b));
1810
1811        let different = parse_clip_path("circle(51px at 1px 2px)").expect("circle");
1812        assert_ne!(a, different);
1813        assert_ne!(hash_of(&a), hash_of(&different));
1814
1815        // The discriminant takes part in the hash, so None and a shape do not
1816        // collide (the manual Hash impls would be easy to write without it).
1817        assert_ne!(hash_of(&ClipPath::None), hash_of(&a));
1818        assert_eq!(hash_of(&ClipPath::None), hash_of(&ClipPath::None));
1819
1820        let outside = parse_shape_outside("circle(50px at 1px 2px)").expect("circle");
1821        assert_ne!(hash_of(&ShapeOutside::None), hash_of(&outside));
1822
1823        let margin = parse_shape_margin("10px").expect("10px");
1824        assert_eq!(
1825            hash_of(&margin),
1826            hash_of(&ShapeMargin {
1827                inner: PixelValue::px(10.0)
1828            })
1829        );
1830        assert_ne!(
1831            hash_of(&margin),
1832            hash_of(&parse_shape_margin("10em").expect("10em"))
1833        );
1834    }
1835}