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