Skip to main content

azul_css/props/layout/
overflow.rs

1//! CSS properties for managing content overflow.
2
3use alloc::string::{String, ToString};
4use crate::corety::{AzString, OptionF32};
5
6use crate::props::formatter::PrintAsCssValue;
7
8// +spec:overflow:647a7b - overflow property (visible/hidden/clip/scroll/auto), overflow-clip-margin, text-overflow defined in CSS Overflow 3
9/// Represents an `overflow-x` or `overflow-y` property.
10///
11/// Determines what to do when content overflows an element's box.
12// +spec:overflow:3526f7 - overflow property with scroll/clip/hidden/visible/auto values
13// +spec:overflow:36c4f6 - overflow-x/overflow-y properties with clip value
14#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15#[repr(C)]
16pub enum LayoutOverflow {
17    /// Always shows a scroll bar, overflows on scroll.
18    Scroll,
19    /// Shows a scroll bar only when content overflows.
20    Auto,
21    /// Clips overflowing content. The rest of the content will be invisible.
22    Hidden,
23    /// Content is not clipped and renders outside the element's box. This is the CSS default.
24    // +spec:overflow:236100 - initial value of 'overflow' is 'visible'
25    #[default]
26    Visible,
27    /// Similar to `hidden`, clips the content at the box's edge.
28    Clip,
29}
30
31impl LayoutOverflow {
32    /// Returns whether this overflow value requires a scrollbar to be displayed.
33    ///
34    /// - `overflow: scroll` always shows the scrollbar.
35    /// - `overflow: auto` only shows the scrollbar if the content is currently overflowing.
36    /// - `overflow: hidden`, `overflow: visible`, and `overflow: clip` do not show any scrollbars.
37    // +spec:overflow:2bf182 - overflow:scroll always shows scrollbar whether or not content is clipped
38    // +spec:overflow:84cd40 - scroll value always displays scrollbar for accessing clipped content
39    // +spec:overflow:8fcdd8 - auto causes scrolling mechanism for overflowing boxes (table exception is UA-level)
40    #[must_use] pub const fn needs_scrollbar(&self, currently_overflowing: bool) -> bool {
41        match self {
42            Self::Scroll => true,
43            Self::Auto => currently_overflowing,
44            Self::Hidden | Self::Visible | Self::Clip => false,
45        }
46    }
47
48    // +spec:overflow:145749 - overflow:hidden clips content to containing element box
49    // +spec:overflow:3dc18e - overflow:hidden clips content with no scrolling UI
50    // +spec:overflow:81e306 - clipping region clips all aspects outside it; clipped content does not cause overflow
51    // +spec:overflow:fd38ce - overflow properties specify whether a box's content is clipped / scroll container
52    /// Returns `true` if this overflow value clips content (everything except `visible`).
53    #[must_use] pub const fn is_clipped(&self) -> bool {
54        // All overflow values except 'visible' clip their content
55        matches!(
56            self,
57            Self::Hidden
58                | Self::Clip
59                | Self::Auto
60                | Self::Scroll
61        )
62    }
63
64    // +spec:overflow:3be57c - overflow:hidden disables user scrolling but programmatic scrolling still works
65    /// Returns `true` if the overflow type is `scroll`.
66    #[must_use] pub const fn is_scroll(&self) -> bool {
67        matches!(self, Self::Scroll)
68    }
69
70    /// Returns `true` if the overflow type is `visible`, which is the only
71    /// overflow type that doesn't clip its children.
72    #[must_use] pub fn is_overflow_visible(&self) -> bool {
73        *self == Self::Visible
74    }
75
76    /// Returns `true` if the overflow type is `hidden`.
77    #[must_use] pub fn is_overflow_hidden(&self) -> bool {
78        *self == Self::Hidden
79    }
80
81    // +spec:overflow:833078 - visible/clip compute to auto/hidden if other axis is scrollable
82    /// Resolves the computed value per CSS Overflow 3 § 3.1:
83    /// visible/clip values compute to auto/hidden (respectively)
84    /// if the other axis is neither visible nor clip.
85    #[must_use] pub const fn resolve_computed(self, other_axis: Self) -> Self {
86        let other_is_scrollable = !matches!(other_axis, Self::Visible | Self::Clip);
87        if other_is_scrollable {
88            match self {
89                Self::Visible => Self::Auto,
90                Self::Clip => Self::Hidden,
91                other => other,
92            }
93        } else {
94            self
95        }
96    }
97}
98
99impl PrintAsCssValue for LayoutOverflow {
100    fn print_as_css_value(&self) -> String {
101        String::from(match self {
102            Self::Scroll => "scroll",
103            Self::Auto => "auto",
104            Self::Hidden => "hidden",
105            Self::Visible => "visible",
106            Self::Clip => "clip",
107        })
108    }
109}
110
111// -- Parser
112
113/// Error returned when parsing an `overflow` property fails.
114#[derive(Clone, PartialEq, Eq)]
115pub enum LayoutOverflowParseError<'a> {
116    /// The provided value is not a valid `overflow` keyword.
117    InvalidValue(&'a str),
118}
119
120impl_debug_as_display!(LayoutOverflowParseError<'a>);
121impl_display! { LayoutOverflowParseError<'a>, {
122    InvalidValue(val) => format!(
123        "Invalid overflow value: \"{}\". Expected 'scroll', 'auto', 'hidden', 'visible', or 'clip'.", val
124    ),
125}}
126
127/// An owned version of `LayoutOverflowParseError`.
128#[derive(Debug, Clone, PartialEq, Eq)]
129#[repr(C, u8)]
130pub enum LayoutOverflowParseErrorOwned {
131    InvalidValue(AzString),
132}
133
134impl LayoutOverflowParseError<'_> {
135    /// Converts the borrowed error into an owned error.
136    #[must_use] pub fn to_contained(&self) -> LayoutOverflowParseErrorOwned {
137        match self {
138            LayoutOverflowParseError::InvalidValue(s) => {
139                LayoutOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
140            }
141        }
142    }
143}
144
145impl LayoutOverflowParseErrorOwned {
146    /// Converts the owned error back into a borrowed error.
147    #[must_use] pub fn to_shared(&self) -> LayoutOverflowParseError<'_> {
148        match self {
149            Self::InvalidValue(s) => {
150                LayoutOverflowParseError::InvalidValue(s.as_str())
151            }
152        }
153    }
154}
155
156#[cfg(feature = "parser")]
157/// Parses a `LayoutOverflow` from a string slice.
158/// # Errors
159///
160/// Returns an error if `input` is not a valid CSS `overflow` value.
161pub fn parse_layout_overflow(
162    input: &str,
163) -> Result<LayoutOverflow, LayoutOverflowParseError<'_>> {
164    let input_trimmed = input.trim();
165    match input_trimmed {
166        "scroll" => Ok(LayoutOverflow::Scroll),
167        "auto" | "overlay" => Ok(LayoutOverflow::Auto), // +spec:overflow:6120e6 - "overlay" is a legacy value alias of "auto"
168        "hidden" => Ok(LayoutOverflow::Hidden),
169        "visible" => Ok(LayoutOverflow::Visible),
170        "clip" => Ok(LayoutOverflow::Clip),
171        _ => Err(LayoutOverflowParseError::InvalidValue(input)),
172    }
173}
174
175// -- StyleScrollbarGutter --
176// +spec:box-model:e98b7c - scrollbar gutter: space between inner border edge and outer padding edge
177
178/// Represents the `scrollbar-gutter` CSS property.
179///
180/// Controls whether space is reserved for the scrollbar, preventing
181/// layout shifts when content overflows.
182// +spec:overflow:da4bbc - scrollbar-gutter affects gutter presence, not scrollbar visibility
183#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
184#[repr(C)]
185pub enum StyleScrollbarGutter {
186    /// No scrollbar gutter is reserved.
187    #[default]
188    Auto,
189    /// Space is reserved for the scrollbar on one edge.
190    Stable,
191    /// Space is reserved for the scrollbar on both edges.
192    StableBothEdges,
193}
194
195impl PrintAsCssValue for StyleScrollbarGutter {
196    fn print_as_css_value(&self) -> String {
197        String::from(match self {
198            Self::Auto => "auto",
199            Self::Stable => "stable",
200            Self::StableBothEdges => "stable both-edges",
201        })
202    }
203}
204
205// -- Parser for StyleScrollbarGutter
206
207/// Error returned when parsing a `scrollbar-gutter` property fails.
208#[derive(Clone, PartialEq, Eq)]
209pub enum StyleScrollbarGutterParseError<'a> {
210    /// The provided value is not a valid `scrollbar-gutter` keyword.
211    InvalidValue(&'a str),
212}
213
214impl_debug_as_display!(StyleScrollbarGutterParseError<'a>);
215impl_display! { StyleScrollbarGutterParseError<'a>, {
216    InvalidValue(val) => format!(
217        "Invalid scrollbar-gutter value: \"{}\". Expected 'auto', 'stable', or 'stable both-edges'.", val
218    ),
219}}
220
221/// An owned version of `StyleScrollbarGutterParseError`.
222#[derive(Debug, Clone, PartialEq, Eq)]
223#[repr(C, u8)]
224pub enum StyleScrollbarGutterParseErrorOwned {
225    InvalidValue(AzString),
226}
227
228impl StyleScrollbarGutterParseError<'_> {
229    /// Converts the borrowed error into an owned error.
230    #[must_use] pub fn to_contained(&self) -> StyleScrollbarGutterParseErrorOwned {
231        match self {
232            StyleScrollbarGutterParseError::InvalidValue(s) => {
233                StyleScrollbarGutterParseErrorOwned::InvalidValue((*s).to_string().into())
234            }
235        }
236    }
237}
238
239impl StyleScrollbarGutterParseErrorOwned {
240    /// Converts the owned error back into a borrowed error.
241    #[must_use] pub fn to_shared(&self) -> StyleScrollbarGutterParseError<'_> {
242        match self {
243            Self::InvalidValue(s) => {
244                StyleScrollbarGutterParseError::InvalidValue(s.as_str())
245            }
246        }
247    }
248}
249
250#[cfg(feature = "parser")]
251/// Parses a `StyleScrollbarGutter` from a string slice.
252/// # Errors
253///
254/// Returns an error if `input` is not a valid CSS `scrollbar-gutter` value.
255pub fn parse_style_scrollbar_gutter(
256    input: &str,
257) -> Result<StyleScrollbarGutter, StyleScrollbarGutterParseError<'_>> {
258    let input_trimmed = input.trim();
259    match input_trimmed {
260        "auto" => Ok(StyleScrollbarGutter::Auto),
261        "stable" => Ok(StyleScrollbarGutter::Stable),
262        "stable both-edges" => Ok(StyleScrollbarGutter::StableBothEdges),
263        _ => Err(StyleScrollbarGutterParseError::InvalidValue(input)),
264    }
265}
266
267// -- StyleTextOverflow --
268// +spec:overflow:647a7b - text-overflow property defined in CSS Overflow 3
269
270/// Represents the `text-overflow` CSS property.
271///
272/// Determines how inline content that is clipped (because the block container
273/// has `overflow` other than `visible`) is signaled to the user at the end of
274/// the line box.
275///
276/// CSS Overflow Module Level 3 §5: <https://www.w3.org/TR/css-overflow-3/#text-overflow>
277#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
278#[repr(C)]
279pub enum StyleTextOverflow {
280    /// Clip the inline content at the edge of its line box. This is the initial value.
281    #[default]
282    Clip,
283    /// Render an ellipsis (`…`, U+2026) to represent clipped inline content.
284    Ellipsis,
285}
286
287impl PrintAsCssValue for StyleTextOverflow {
288    fn print_as_css_value(&self) -> String {
289        String::from(match self {
290            Self::Clip => "clip",
291            Self::Ellipsis => "ellipsis",
292        })
293    }
294}
295
296// -- Parser for StyleTextOverflow
297
298/// Error returned when parsing a `text-overflow` property fails.
299#[derive(Clone, PartialEq, Eq)]
300pub enum StyleTextOverflowParseError<'a> {
301    /// The provided value is not a valid `text-overflow` keyword.
302    InvalidValue(&'a str),
303}
304
305impl_debug_as_display!(StyleTextOverflowParseError<'a>);
306impl_display! { StyleTextOverflowParseError<'a>, {
307    InvalidValue(val) => format!(
308        "Invalid text-overflow value: \"{}\". Expected 'clip' or 'ellipsis'.", val
309    ),
310}}
311
312/// An owned version of `StyleTextOverflowParseError`.
313#[derive(Debug, Clone, PartialEq, Eq)]
314#[repr(C, u8)]
315pub enum StyleTextOverflowParseErrorOwned {
316    InvalidValue(AzString),
317}
318
319impl StyleTextOverflowParseError<'_> {
320    /// Converts the borrowed error into an owned error.
321    #[must_use] pub fn to_contained(&self) -> StyleTextOverflowParseErrorOwned {
322        match self {
323            StyleTextOverflowParseError::InvalidValue(s) => {
324                StyleTextOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
325            }
326        }
327    }
328}
329
330impl StyleTextOverflowParseErrorOwned {
331    /// Converts the owned error back into a borrowed error.
332    #[must_use] pub fn to_shared(&self) -> StyleTextOverflowParseError<'_> {
333        match self {
334            Self::InvalidValue(s) => {
335                StyleTextOverflowParseError::InvalidValue(s.as_str())
336            }
337        }
338    }
339}
340
341#[cfg(feature = "parser")]
342/// Parses a `StyleTextOverflow` from a string slice.
343/// # Errors
344///
345/// Returns an error if `input` is not a valid CSS `text-overflow` value.
346pub fn parse_style_text_overflow(
347    input: &str,
348) -> Result<StyleTextOverflow, StyleTextOverflowParseError<'_>> {
349    match input.trim() {
350        "clip" => Ok(StyleTextOverflow::Clip),
351        "ellipsis" => Ok(StyleTextOverflow::Ellipsis),
352        other => Err(StyleTextOverflowParseError::InvalidValue(other)),
353    }
354}
355
356// -- VisualBox --
357
358// +spec:overflow:f6955f - box edge origin for overflow-clip-margin
359/// Represents the `<visual-box>` value used as the overflow clip edge origin.
360///
361/// Specifies which box edge to use as the starting point for the clip region.
362/// Defaults to `padding-box` per CSS Overflow Module Level 3.
363#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
364#[repr(C)]
365pub enum VisualBox {
366    /// Clip edge starts at the content box edge.
367    ContentBox,
368    /// Clip edge starts at the padding box edge (default).
369    #[default]
370    PaddingBox,
371    /// Clip edge starts at the border box edge.
372    BorderBox,
373}
374
375impl PrintAsCssValue for VisualBox {
376    fn print_as_css_value(&self) -> String {
377        String::from(match self {
378            Self::ContentBox => "content-box",
379            Self::PaddingBox => "padding-box",
380            Self::BorderBox => "border-box",
381        })
382    }
383}
384
385// -- StyleOverflowClipMargin --
386
387/// Represents the `overflow-clip-margin` CSS property.
388///
389/// Determines how far outside the element's box the content may paint
390/// before being clipped when `overflow: clip` is used.
391/// Syntax: `<visual-box> || <length [0,∞]>`
392// +spec:overflow:455786 - overflow-clip-margin has no effect on hidden/scroll, only on clip
393#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
394#[repr(C)]
395pub struct StyleOverflowClipMargin {
396    /// The box edge to use as the clip origin (content-box, padding-box, or border-box).
397    pub clip_edge: VisualBox,
398    /// The clip margin distance beyond the clip edge.
399    pub inner: crate::props::basic::pixel::PixelValue,
400}
401
402impl PrintAsCssValue for StyleOverflowClipMargin {
403    fn print_as_css_value(&self) -> String {
404        let edge = self.clip_edge.print_as_css_value();
405        let len = self.inner.print_as_css_value();
406        #[allow(clippy::float_cmp)] // exact zero check: value is default-initialized, not computed
407        if self.inner.number.get() == 0.0 {
408            edge
409        } else if self.clip_edge == VisualBox::PaddingBox {
410            len
411        } else {
412            format!("{edge} {len}")
413        }
414    }
415}
416
417/// Error returned when parsing an `overflow-clip-margin` property fails.
418#[derive(Clone, PartialEq, Eq)]
419pub enum StyleOverflowClipMarginParseError<'a> {
420    /// The provided value is not a valid `overflow-clip-margin` value.
421    InvalidValue(&'a str),
422}
423
424impl_debug_as_display!(StyleOverflowClipMarginParseError<'a>);
425impl_display! { StyleOverflowClipMarginParseError<'a>, {
426    InvalidValue(val) => format!("Invalid overflow-clip-margin value: \"{}\"", val),
427}}
428
429/// An owned version of `StyleOverflowClipMarginParseError`.
430#[derive(Debug, Clone, PartialEq, Eq)]
431#[repr(C, u8)]
432pub enum StyleOverflowClipMarginParseErrorOwned {
433    InvalidValue(AzString),
434}
435
436impl StyleOverflowClipMarginParseError<'_> {
437    /// Converts the borrowed error into an owned error.
438    #[must_use] pub fn to_contained(&self) -> StyleOverflowClipMarginParseErrorOwned {
439        match self {
440            StyleOverflowClipMarginParseError::InvalidValue(s) => {
441                StyleOverflowClipMarginParseErrorOwned::InvalidValue((*s).to_string().into())
442            }
443        }
444    }
445}
446
447impl StyleOverflowClipMarginParseErrorOwned {
448    /// Converts the owned error back into a borrowed error.
449    #[must_use] pub fn to_shared(&self) -> StyleOverflowClipMarginParseError<'_> {
450        match self {
451            Self::InvalidValue(s) => {
452                StyleOverflowClipMarginParseError::InvalidValue(s.as_str())
453            }
454        }
455    }
456}
457
458#[cfg(feature = "parser")]
459/// Parses a `StyleOverflowClipMargin` from a string slice.
460///
461/// Syntax: `<visual-box> || <length [0,∞]>`
462/// The `<visual-box>` defaults to `padding-box` if omitted.
463/// The `<length>` defaults to `0px` if omitted.
464/// # Errors
465///
466/// Returns an error if `input` is not a valid CSS `overflow-clip-margin` value.
467pub fn parse_style_overflow_clip_margin(
468    input: &str,
469) -> Result<StyleOverflowClipMargin, StyleOverflowClipMarginParseError<'_>> {
470    use crate::props::basic::pixel::parse_pixel_value;
471
472    let input_trimmed = input.trim();
473    let mut clip_edge = None;
474    let mut length = None;
475
476    for token in input_trimmed.split_whitespace() {
477        match token {
478            "content-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::ContentBox),
479            "padding-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::PaddingBox),
480            "border-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::BorderBox),
481            _ if length.is_none() => {
482                match parse_pixel_value(token) {
483                    Ok(pv) => length = Some(pv),
484                    Err(_) => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
485                }
486            }
487            _ => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
488        }
489    }
490
491    if clip_edge.is_none() && length.is_none() {
492        return Err(StyleOverflowClipMarginParseError::InvalidValue(input));
493    }
494
495    Ok(StyleOverflowClipMargin {
496        clip_edge: clip_edge.unwrap_or_default(),
497        inner: length.unwrap_or_default(),
498    })
499}
500
501// -- StyleClipRect --
502
503/// Represents the deprecated CSS `clip` property value `rect(top, right, bottom, left)`.
504///
505/// Each edge can be a length or `auto`. When `auto`, the edge matches the
506/// element's generated border box edge:
507/// - `auto` for top/left = 0
508/// - `auto` for bottom = used height + vertical padding + vertical border
509/// - `auto` for right = used width + horizontal padding + horizontal border
510///
511/// Negative lengths are permitted.
512// +spec:overflow:297dc3 - clip rect() auto values resolve to border box edges
513#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
514#[repr(C)]
515pub struct StyleClipRect {
516    /// Top edge offset in pixels. `None` means `auto` (= 0).
517    pub top: OptionF32,
518    /// Right edge offset in pixels. `None` means `auto` (= used width + horiz padding + horiz border).
519    pub right: OptionF32,
520    /// Bottom edge offset in pixels. `None` means `auto` (= used height + vert padding + vert border).
521    pub bottom: OptionF32,
522    /// Left edge offset in pixels. `None` means `auto` (= 0).
523    pub left: OptionF32,
524}
525
526impl StyleClipRect {
527    /// Resolves `auto` values to border box edges given the element's
528    /// used width/height and padding/border sizes.
529    ///
530    /// Returns `(top, right, bottom, left)` in pixels.
531    #[must_use] pub fn resolve(
532        &self,
533        used_width: f32,
534        used_height: f32,
535        padding_left: f32,
536        padding_right: f32,
537        padding_top: f32,
538        padding_bottom: f32,
539        border_left: f32,
540        border_right: f32,
541        border_top: f32,
542        border_bottom: f32,
543    ) -> (f32, f32, f32, f32) {
544        let top = self.top.into_option().unwrap_or(0.0);
545        let left = self.left.into_option().unwrap_or(0.0);
546        let bottom = self
547            .bottom
548            .into_option()
549            .unwrap_or(used_height + padding_top + padding_bottom + border_top + border_bottom);
550        let right = self
551            .right
552            .into_option()
553            .unwrap_or(used_width + padding_left + padding_right + border_left + border_right);
554        (top, right, bottom, left)
555    }
556}
557
558impl PrintAsCssValue for StyleClipRect {
559    fn print_as_css_value(&self) -> String {
560        fn fmt_edge(o: OptionF32) -> String {
561            o.into_option()
562                .map_or_else(|| String::from("auto"), |v| format!("{v}px"))
563        }
564        format!(
565            "rect({}, {}, {}, {})",
566            fmt_edge(self.top),
567            fmt_edge(self.right),
568            fmt_edge(self.bottom),
569            fmt_edge(self.left)
570        )
571    }
572}
573
574// -- Parser for StyleClipRect
575
576/// Error returned when parsing a CSS `clip` property value fails.
577#[derive(Clone, PartialEq, Eq)]
578pub enum StyleClipRectParseError<'a> {
579    /// The provided value is not a valid `clip` value.
580    InvalidValue(&'a str),
581}
582
583impl_debug_as_display!(StyleClipRectParseError<'a>);
584impl_display! { StyleClipRectParseError<'a>, {
585    InvalidValue(val) => format!(
586        "Invalid clip value: \"{}\". Expected 'auto' or 'rect(<top>, <right>, <bottom>, <left>)'.", val
587    ),
588}}
589
590/// An owned version of `StyleClipRectParseError`.
591#[derive(Debug, Clone, PartialEq, Eq)]
592#[repr(C, u8)]
593pub enum StyleClipRectParseErrorOwned {
594    InvalidValue(AzString),
595}
596
597impl StyleClipRectParseError<'_> {
598    /// Converts the borrowed error into an owned error.
599    #[must_use] pub fn to_contained(&self) -> StyleClipRectParseErrorOwned {
600        match self {
601            StyleClipRectParseError::InvalidValue(s) => {
602                StyleClipRectParseErrorOwned::InvalidValue((*s).to_string().into())
603            }
604        }
605    }
606}
607
608impl StyleClipRectParseErrorOwned {
609    /// Converts the owned error back into a borrowed error.
610    #[must_use] pub fn to_shared(&self) -> StyleClipRectParseError<'_> {
611        match self {
612            Self::InvalidValue(s) => {
613                StyleClipRectParseError::InvalidValue(s.as_str())
614            }
615        }
616    }
617}
618
619#[cfg(feature = "parser")]
620fn parse_clip_edge(token: &str) -> Result<OptionF32, StyleClipRectParseError<'_>> {
621    use crate::props::basic::pixel::parse_pixel_value;
622
623    let token = token.trim();
624    if token.eq_ignore_ascii_case("auto") {
625        return Ok(OptionF32::None);
626    }
627    let pv = parse_pixel_value(token)
628        .map_err(|_| StyleClipRectParseError::InvalidValue(token))?;
629    Ok(OptionF32::Some(pv.number.get()))
630}
631
632#[cfg(feature = "parser")]
633/// Parses a `StyleClipRect` from a string slice.
634///
635/// Accepts:
636/// - `auto` — equivalent to `rect(auto, auto, auto, auto)`.
637/// - `rect(<top>, <right>, <bottom>, <left>)` — comma-separated form.
638/// - `rect(<top> <right> <bottom> <left>)` — legacy space-separated form.
639///
640/// Each edge is either `auto` or a `<length>`. Negative lengths are permitted.
641/// # Errors
642///
643/// Returns an error if `input` is not a valid CSS `clip-rect` value.
644pub fn parse_clip_rect(input: &str) -> Result<StyleClipRect, StyleClipRectParseError<'_>> {
645    let trimmed = input.trim();
646
647    if trimmed.eq_ignore_ascii_case("auto") {
648        return Ok(StyleClipRect::default());
649    }
650
651    let inner = trimmed
652        .strip_prefix("rect(")
653        .or_else(|| trimmed.strip_prefix("RECT("))
654        .and_then(|s| s.strip_suffix(')'))
655        .ok_or(StyleClipRectParseError::InvalidValue(input))?;
656
657    let inner = inner.trim();
658    let parts: Vec<&str> = if inner.contains(',') {
659        inner.split(',').map(str::trim).collect()
660    } else {
661        inner.split_whitespace().collect()
662    };
663
664    if parts.len() != 4 {
665        return Err(StyleClipRectParseError::InvalidValue(input));
666    }
667
668    Ok(StyleClipRect {
669        top: parse_clip_edge(parts[0])?,
670        right: parse_clip_edge(parts[1])?,
671        bottom: parse_clip_edge(parts[2])?,
672        left: parse_clip_edge(parts[3])?,
673    })
674}
675
676#[cfg(all(test, feature = "parser"))]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_parse_layout_overflow_valid() {
682        assert_eq!(
683            parse_layout_overflow("visible").unwrap(),
684            LayoutOverflow::Visible
685        );
686        assert_eq!(
687            parse_layout_overflow("hidden").unwrap(),
688            LayoutOverflow::Hidden
689        );
690        assert_eq!(parse_layout_overflow("clip").unwrap(), LayoutOverflow::Clip);
691        assert_eq!(
692            parse_layout_overflow("scroll").unwrap(),
693            LayoutOverflow::Scroll
694        );
695        assert_eq!(parse_layout_overflow("auto").unwrap(), LayoutOverflow::Auto);
696    }
697
698    #[test]
699    fn test_parse_style_text_overflow_valid() {
700        assert_eq!(
701            parse_style_text_overflow("clip").unwrap(),
702            StyleTextOverflow::Clip
703        );
704        assert_eq!(
705            parse_style_text_overflow("ellipsis").unwrap(),
706            StyleTextOverflow::Ellipsis
707        );
708        // whitespace is tolerated
709        assert_eq!(
710            parse_style_text_overflow("  ellipsis  ").unwrap(),
711            StyleTextOverflow::Ellipsis
712        );
713        // initial value is `clip`
714        assert_eq!(StyleTextOverflow::default(), StyleTextOverflow::Clip);
715    }
716
717    #[test]
718    fn test_parse_style_text_overflow_invalid() {
719        assert!(parse_style_text_overflow("none").is_err());
720        assert!(parse_style_text_overflow("").is_err());
721        assert!(parse_style_text_overflow("fade").is_err());
722        // error message names the property and quotes the value
723        let msg = format!(
724            "{}",
725            StyleTextOverflowParseError::InvalidValue("fade")
726        );
727        assert!(msg.contains("text-overflow") && msg.contains("fade"), "{msg}");
728        // owned <-> shared round-trips
729        let e = parse_style_text_overflow("fade").unwrap_err();
730        assert_eq!(e.to_contained().to_shared(), e);
731    }
732
733    #[test]
734    fn test_style_text_overflow_print_round_trip() {
735        for v in [StyleTextOverflow::Clip, StyleTextOverflow::Ellipsis] {
736            let printed = v.print_as_css_value();
737            assert_eq!(parse_style_text_overflow(&printed).unwrap(), v);
738        }
739    }
740
741    #[test]
742    fn test_parse_layout_overflow_whitespace() {
743        assert_eq!(
744            parse_layout_overflow("  scroll  ").unwrap(),
745            LayoutOverflow::Scroll
746        );
747    }
748
749    #[test]
750    fn test_parse_layout_overflow_invalid() {
751        assert!(parse_layout_overflow("none").is_err());
752        assert!(parse_layout_overflow("").is_err());
753        assert!(parse_layout_overflow("auto scroll").is_err());
754        assert!(parse_layout_overflow("hidden-x").is_err());
755    }
756
757    #[test]
758    fn test_needs_scrollbar() {
759        assert!(LayoutOverflow::Scroll.needs_scrollbar(false));
760        assert!(LayoutOverflow::Scroll.needs_scrollbar(true));
761        assert!(LayoutOverflow::Auto.needs_scrollbar(true));
762        assert!(!LayoutOverflow::Auto.needs_scrollbar(false));
763        assert!(!LayoutOverflow::Hidden.needs_scrollbar(true));
764        assert!(!LayoutOverflow::Visible.needs_scrollbar(true));
765        assert!(!LayoutOverflow::Clip.needs_scrollbar(true));
766    }
767
768    #[test]
769    fn test_parse_clip_rect_auto_keyword() {
770        let r = parse_clip_rect("auto").unwrap();
771        assert_eq!(r.top, OptionF32::None);
772        assert_eq!(r.right, OptionF32::None);
773        assert_eq!(r.bottom, OptionF32::None);
774        assert_eq!(r.left, OptionF32::None);
775    }
776
777    #[test]
778    fn test_parse_clip_rect_all_auto_in_rect() {
779        let r = parse_clip_rect("rect(auto, auto, auto, auto)").unwrap();
780        assert_eq!(r.top, OptionF32::None);
781        assert_eq!(r.right, OptionF32::None);
782        assert_eq!(r.bottom, OptionF32::None);
783        assert_eq!(r.left, OptionF32::None);
784    }
785
786    #[test]
787    fn test_parse_clip_rect_mixed_auto_and_lengths() {
788        let r = parse_clip_rect("rect(10px, auto, 30px, auto)").unwrap();
789        assert_eq!(r.top, OptionF32::Some(10.0));
790        assert_eq!(r.right, OptionF32::None);
791        assert_eq!(r.bottom, OptionF32::Some(30.0));
792        assert_eq!(r.left, OptionF32::None);
793    }
794
795    #[test]
796    fn test_parse_clip_rect_negative_lengths() {
797        let r = parse_clip_rect("rect(-5px, 0px, -10px, 0px)").unwrap();
798        assert_eq!(r.top, OptionF32::Some(-5.0));
799        assert_eq!(r.right, OptionF32::Some(0.0));
800        assert_eq!(r.bottom, OptionF32::Some(-10.0));
801        assert_eq!(r.left, OptionF32::Some(0.0));
802    }
803
804    #[test]
805    fn test_parse_clip_rect_legacy_space_separated() {
806        // Legacy CSS 2.1 syntax used spaces instead of commas.
807        let r = parse_clip_rect("rect(1px 2px 3px 4px)").unwrap();
808        assert_eq!(r.top, OptionF32::Some(1.0));
809        assert_eq!(r.right, OptionF32::Some(2.0));
810        assert_eq!(r.bottom, OptionF32::Some(3.0));
811        assert_eq!(r.left, OptionF32::Some(4.0));
812    }
813
814    #[test]
815    fn test_parse_clip_rect_malformed() {
816        assert!(parse_clip_rect("").is_err());
817        assert!(parse_clip_rect("none").is_err());
818        // Wrong number of edges.
819        assert!(parse_clip_rect("rect(10px, 20px, 30px)").is_err());
820        // Missing closing paren.
821        assert!(parse_clip_rect("rect(10px, 20px, 30px, 40px").is_err());
822        // Garbage edge.
823        assert!(parse_clip_rect("rect(10px, abc, 30px, 40px)").is_err());
824    }
825}
826
827#[cfg(all(test, feature = "parser"))]
828mod autotest_generated {
829    use crate::props::basic::pixel::PixelValue;
830    use crate::props::basic::length::SizeMetric;
831
832    use super::*;
833
834    // ---------------------------------------------------------------------
835    // Variant tables. Each is kept honest by an exhaustive `match` below:
836    // adding a variant to the enum stops the index fn from compiling.
837    // ---------------------------------------------------------------------
838
839    const ALL_OVERFLOW: [LayoutOverflow; 5] = [
840        LayoutOverflow::Scroll,
841        LayoutOverflow::Auto,
842        LayoutOverflow::Hidden,
843        LayoutOverflow::Visible,
844        LayoutOverflow::Clip,
845    ];
846
847    const fn overflow_variant_index(o: LayoutOverflow) -> usize {
848        match o {
849            LayoutOverflow::Scroll => 0,
850            LayoutOverflow::Auto => 1,
851            LayoutOverflow::Hidden => 2,
852            LayoutOverflow::Visible => 3,
853            LayoutOverflow::Clip => 4,
854        }
855    }
856
857    const ALL_GUTTER: [StyleScrollbarGutter; 3] = [
858        StyleScrollbarGutter::Auto,
859        StyleScrollbarGutter::Stable,
860        StyleScrollbarGutter::StableBothEdges,
861    ];
862
863    const fn gutter_variant_index(g: StyleScrollbarGutter) -> usize {
864        match g {
865            StyleScrollbarGutter::Auto => 0,
866            StyleScrollbarGutter::Stable => 1,
867            StyleScrollbarGutter::StableBothEdges => 2,
868        }
869    }
870
871    const ALL_VISUAL_BOX: [VisualBox; 3] = [
872        VisualBox::ContentBox,
873        VisualBox::PaddingBox,
874        VisualBox::BorderBox,
875    ];
876
877    const fn visual_box_variant_index(v: VisualBox) -> usize {
878        match v {
879            VisualBox::ContentBox => 0,
880            VisualBox::PaddingBox => 1,
881            VisualBox::BorderBox => 2,
882        }
883    }
884
885    /// A value is "scrollable" (per CSS Overflow 3 § 3.1) when it is neither
886    /// `visible` nor `clip` — i.e. it establishes a scroll container.
887    const fn is_scrollable(o: LayoutOverflow) -> bool {
888        !matches!(o, LayoutOverflow::Visible | LayoutOverflow::Clip)
889    }
890
891    #[test]
892    fn variant_tables_cover_every_variant_exactly_once() {
893        for (i, o) in ALL_OVERFLOW.iter().enumerate() {
894            assert_eq!(overflow_variant_index(*o), i);
895        }
896        for (i, g) in ALL_GUTTER.iter().enumerate() {
897            assert_eq!(gutter_variant_index(*g), i);
898        }
899        for (i, v) in ALL_VISUAL_BOX.iter().enumerate() {
900            assert_eq!(visual_box_variant_index(*v), i);
901        }
902    }
903
904    // ---------------------------------------------------------------------
905    // LayoutOverflow — predicates & invariants
906    // ---------------------------------------------------------------------
907
908    #[test]
909    fn needs_scrollbar_truth_table_is_monotone_in_currently_overflowing() {
910        for o in ALL_OVERFLOW {
911            let idle = o.needs_scrollbar(false);
912            let overflowing = o.needs_scrollbar(true);
913
914            // A scrollbar that is shown while *not* overflowing must also be
915            // shown while overflowing — the flag can only ever add scrollbars.
916            assert!(
917                !idle || overflowing,
918                "{o:?} shows a scrollbar when idle but hides it when overflowing"
919            );
920
921            // Only `scroll` shows a scrollbar unconditionally; only `auto`
922            // reacts to the flag; nothing else ever shows one.
923            let (expect_idle, expect_overflowing) = match o {
924                LayoutOverflow::Scroll => (true, true),
925                LayoutOverflow::Auto => (false, true),
926                LayoutOverflow::Hidden | LayoutOverflow::Visible | LayoutOverflow::Clip => {
927                    (false, false)
928                }
929            };
930            assert_eq!(idle, expect_idle, "needs_scrollbar(false) wrong for {o:?}");
931            assert_eq!(
932                overflowing, expect_overflowing,
933                "needs_scrollbar(true) wrong for {o:?}"
934            );
935
936            // Anything that can show a scrollbar must also clip.
937            assert!(!overflowing || o.is_clipped());
938        }
939    }
940
941    #[test]
942    fn is_clipped_is_exactly_the_negation_of_is_overflow_visible() {
943        for o in ALL_OVERFLOW {
944            assert_eq!(
945                o.is_clipped(),
946                !o.is_overflow_visible(),
947                "is_clipped/is_overflow_visible disagree for {o:?}"
948            );
949            // Deterministic: repeated calls on the same value never differ.
950            assert_eq!(o.is_clipped(), o.is_clipped());
951        }
952        assert!(!LayoutOverflow::Visible.is_clipped());
953        assert!(LayoutOverflow::Hidden.is_clipped());
954    }
955
956    #[test]
957    fn is_scroll_and_is_overflow_hidden_match_exactly_one_variant_each() {
958        let scrolls: Vec<LayoutOverflow> =
959            ALL_OVERFLOW.into_iter().filter(LayoutOverflow::is_scroll).collect();
960        assert_eq!(scrolls, vec![LayoutOverflow::Scroll]);
961
962        let hiddens: Vec<LayoutOverflow> = ALL_OVERFLOW
963            .into_iter()
964            .filter(LayoutOverflow::is_overflow_hidden)
965            .collect();
966        assert_eq!(hiddens, vec![LayoutOverflow::Hidden]);
967
968        // `auto` is not `scroll`, even though both can produce a scrollbar.
969        assert!(!LayoutOverflow::Auto.is_scroll());
970        assert!(LayoutOverflow::Auto.needs_scrollbar(true));
971    }
972
973    #[test]
974    fn default_overflow_is_visible_and_neither_clips_nor_scrolls() {
975        let d = LayoutOverflow::default();
976        assert_eq!(d, LayoutOverflow::Visible);
977        assert!(d.is_overflow_visible());
978        assert!(!d.is_clipped());
979        assert!(!d.is_scroll());
980        assert!(!d.is_overflow_hidden());
981        assert!(!d.needs_scrollbar(false));
982        assert!(!d.needs_scrollbar(true));
983    }
984
985    // ---------------------------------------------------------------------
986    // LayoutOverflow::resolve_computed — CSS Overflow 3 § 3.1
987    // ---------------------------------------------------------------------
988
989    #[test]
990    fn resolve_computed_is_identity_when_the_other_axis_is_not_scrollable() {
991        for other in [LayoutOverflow::Visible, LayoutOverflow::Clip] {
992            for o in ALL_OVERFLOW {
993                assert_eq!(
994                    o.resolve_computed(other),
995                    o,
996                    "{o:?} must be untouched when the other axis is {other:?}"
997                );
998            }
999        }
1000    }
1001
1002    #[test]
1003    fn resolve_computed_promotes_visible_to_auto_and_clip_to_hidden() {
1004        for other in [
1005            LayoutOverflow::Scroll,
1006            LayoutOverflow::Auto,
1007            LayoutOverflow::Hidden,
1008        ] {
1009            assert_eq!(
1010                LayoutOverflow::Visible.resolve_computed(other),
1011                LayoutOverflow::Auto
1012            );
1013            assert_eq!(
1014                LayoutOverflow::Clip.resolve_computed(other),
1015                LayoutOverflow::Hidden
1016            );
1017            // Already-scrollable values are left alone.
1018            for o in [
1019                LayoutOverflow::Scroll,
1020                LayoutOverflow::Auto,
1021                LayoutOverflow::Hidden,
1022            ] {
1023                assert_eq!(o.resolve_computed(other), o);
1024            }
1025        }
1026    }
1027
1028    #[test]
1029    fn resolve_computed_is_idempotent_and_never_removes_clipping() {
1030        for o in ALL_OVERFLOW {
1031            for other in ALL_OVERFLOW {
1032                let once = o.resolve_computed(other);
1033                assert_eq!(
1034                    once.resolve_computed(other),
1035                    once,
1036                    "resolve_computed not idempotent for ({o:?}, {other:?})"
1037                );
1038                // Resolution only ever adds clipping, never takes it away.
1039                assert!(
1040                    !o.is_clipped() || once.is_clipped(),
1041                    "({o:?}, {other:?}) lost clipping"
1042                );
1043                // ...and never turns a scroll container back into a non-scroller.
1044                assert!(!is_scrollable(o) || is_scrollable(once));
1045            }
1046        }
1047    }
1048
1049    #[test]
1050    fn resolve_computed_leaves_both_axes_consistently_scrollable() {
1051        // The whole point of the rule: after resolving *both* axes against each
1052        // other you can never end up with one scrollable axis and one that is
1053        // still visible/clip (which would be unrenderable).
1054        for x in ALL_OVERFLOW {
1055            for y in ALL_OVERFLOW {
1056                let rx = x.resolve_computed(y);
1057                let ry = y.resolve_computed(x);
1058                assert_eq!(
1059                    is_scrollable(rx),
1060                    is_scrollable(ry),
1061                    "({x:?}, {y:?}) resolved to the mismatched pair ({rx:?}, {ry:?})"
1062                );
1063            }
1064        }
1065
1066        // Spot-check the documented pairs.
1067        assert_eq!(
1068            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Scroll),
1069            LayoutOverflow::Auto
1070        );
1071        assert_eq!(
1072            LayoutOverflow::Scroll.resolve_computed(LayoutOverflow::Visible),
1073            LayoutOverflow::Scroll
1074        );
1075        // visible + clip is a legal pair and must survive untouched.
1076        assert_eq!(
1077            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Clip),
1078            LayoutOverflow::Visible
1079        );
1080        assert_eq!(
1081            LayoutOverflow::Clip.resolve_computed(LayoutOverflow::Visible),
1082            LayoutOverflow::Clip
1083        );
1084    }
1085
1086    // ---------------------------------------------------------------------
1087    // parse_layout_overflow
1088    // ---------------------------------------------------------------------
1089
1090    #[test]
1091    fn layout_overflow_round_trips_through_print_as_css_value() {
1092        for o in ALL_OVERFLOW {
1093            let printed = o.print_as_css_value();
1094            assert_eq!(
1095                parse_layout_overflow(&printed).unwrap(),
1096                o,
1097                "{o:?} printed as {printed:?} did not round-trip"
1098            );
1099            // The printed form is a bare keyword: no whitespace, all lowercase.
1100            assert!(!printed.is_empty());
1101            assert!(!printed.contains(char::is_whitespace));
1102            assert_eq!(printed, printed.to_lowercase());
1103        }
1104    }
1105
1106    #[test]
1107    fn parse_layout_overflow_treats_overlay_as_a_one_way_alias_of_auto() {
1108        // "overlay" is a legacy alias that parses to Auto but is never printed,
1109        // so the round-trip is stable only after the first normalisation.
1110        assert_eq!(parse_layout_overflow("overlay").unwrap(), LayoutOverflow::Auto);
1111        let normalised = parse_layout_overflow("overlay").unwrap().print_as_css_value();
1112        assert_eq!(normalised, "auto");
1113        assert_eq!(
1114            parse_layout_overflow(&normalised).unwrap(),
1115            LayoutOverflow::Auto
1116        );
1117        for o in ALL_OVERFLOW {
1118            assert_ne!(o.print_as_css_value(), "overlay");
1119        }
1120    }
1121
1122    #[test]
1123    fn parse_layout_overflow_rejects_empty_and_whitespace_only_input() {
1124        for input in ["", " ", "   ", "\t", "\n", "\r\n", "\t \n \r", "\u{00A0}"] {
1125            assert!(
1126                parse_layout_overflow(input).is_err(),
1127                "{input:?} must not parse"
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn parse_layout_overflow_error_carries_the_untrimmed_input() {
1134        // The parser trims for matching but reports the *original* slice.
1135        let err = parse_layout_overflow("  bogus  ").unwrap_err();
1136        assert_eq!(err, LayoutOverflowParseError::InvalidValue("  bogus  "));
1137        let msg = format!("{err}");
1138        assert!(msg.contains("bogus"), "{msg}");
1139        assert!(msg.contains("scroll"), "error should list the valid keywords: {msg}");
1140    }
1141
1142    #[test]
1143    fn parse_layout_overflow_is_ascii_case_sensitive() {
1144        // NOTE: CSS keywords are ASCII case-insensitive, but this parser only
1145        // accepts the lowercase spelling (property *names* are lowercased
1146        // upstream, values are not). Characterised here so a future fix has to
1147        // update the test deliberately.
1148        for input in ["SCROLL", "Scroll", "sCrOlL", "AUTO", "Hidden", "VISIBLE", "Clip"] {
1149            assert!(
1150                parse_layout_overflow(input).is_err(),
1151                "{input:?} unexpectedly parsed"
1152            );
1153        }
1154        assert_eq!(parse_layout_overflow("scroll").unwrap(), LayoutOverflow::Scroll);
1155    }
1156
1157    #[test]
1158    fn parse_layout_overflow_trims_unicode_whitespace_but_not_zero_width_chars() {
1159        // `str::trim` uses the Unicode White_Space property, which is wider than
1160        // CSS whitespace: NBSP and the ideographic space are stripped too.
1161        assert_eq!(
1162            parse_layout_overflow("\u{00A0}scroll\u{00A0}").unwrap(),
1163            LayoutOverflow::Scroll
1164        );
1165        assert_eq!(
1166            parse_layout_overflow("\u{3000}auto").unwrap(),
1167            LayoutOverflow::Auto
1168        );
1169        // ...but a zero-width space is not whitespace, so it stays and rejects.
1170        assert!(parse_layout_overflow("\u{200B}scroll").is_err());
1171        assert!(parse_layout_overflow("scroll\u{FEFF}").is_err());
1172    }
1173
1174    #[test]
1175    fn parse_layout_overflow_rejects_garbage_unicode_and_boundary_numbers() {
1176        for input in [
1177            "none",
1178            "hidden-x",
1179            "auto scroll",
1180            "scroll;",
1181            "scroll garbage",
1182            "visible !important",
1183            "\0",
1184            "scroll\0",
1185            "!@#$%^&*()",
1186            "\u{1F600}",
1187            "scroll\u{1F600}",
1188            "e\u{0301}",
1189            "scroll",
1190            "скролл",
1191            "0",
1192            "-0",
1193            "0.0",
1194            "NaN",
1195            "nan",
1196            "inf",
1197            "-inf",
1198            "infinity",
1199            "9223372036854775807",
1200            "-9223372036854775808",
1201            "1e400",
1202            "1e-400",
1203        ] {
1204            assert!(
1205                parse_layout_overflow(input).is_err(),
1206                "{input:?} unexpectedly parsed"
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn parse_layout_overflow_survives_extremely_long_and_deeply_nested_input() {
1213        let long = "scroll".repeat(200_000);
1214        assert!(parse_layout_overflow(&long).is_err());
1215
1216        let junk = "a".repeat(1_000_000);
1217        assert!(parse_layout_overflow(&junk).is_err());
1218
1219        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1220        assert!(parse_layout_overflow(&nested).is_err());
1221
1222        // A valid keyword buried in megabytes of padding is still just padding.
1223        let padded = format!("{}scroll{}", " ".repeat(500_000), " ".repeat(500_000));
1224        assert_eq!(parse_layout_overflow(&padded).unwrap(), LayoutOverflow::Scroll);
1225    }
1226
1227    // ---------------------------------------------------------------------
1228    // parse_style_scrollbar_gutter
1229    // ---------------------------------------------------------------------
1230
1231    #[test]
1232    fn scrollbar_gutter_round_trips_through_print_as_css_value() {
1233        for g in ALL_GUTTER {
1234            let printed = g.print_as_css_value();
1235            assert_eq!(
1236                parse_style_scrollbar_gutter(&printed).unwrap(),
1237                g,
1238                "{g:?} printed as {printed:?} did not round-trip"
1239            );
1240        }
1241        assert_eq!(
1242            StyleScrollbarGutter::StableBothEdges.print_as_css_value(),
1243            "stable both-edges"
1244        );
1245        assert_eq!(StyleScrollbarGutter::default(), StyleScrollbarGutter::Auto);
1246    }
1247
1248    #[test]
1249    fn parse_style_scrollbar_gutter_matches_the_keyword_string_verbatim() {
1250        // The parser compares the whole trimmed string, so it accepts exactly
1251        // one ASCII space between `stable` and `both-edges`. Per the grammar
1252        // (`auto | stable && both-edges?`) the reversed order and collapsed
1253        // runs of whitespace should also be legal — characterising the gap.
1254        assert_eq!(
1255            parse_style_scrollbar_gutter("stable both-edges").unwrap(),
1256            StyleScrollbarGutter::StableBothEdges
1257        );
1258        for rejected in [
1259            "stable  both-edges", // two spaces
1260            "stable\tboth-edges",
1261            "stable\nboth-edges",
1262            "both-edges stable", // `&&` allows either order
1263            "both-edges",
1264            "STABLE",
1265            "Stable Both-Edges",
1266            "stable both-edges stable",
1267        ] {
1268            assert!(
1269                parse_style_scrollbar_gutter(rejected).is_err(),
1270                "{rejected:?} unexpectedly parsed"
1271            );
1272        }
1273        // Outer whitespace *is* trimmed.
1274        assert_eq!(
1275            parse_style_scrollbar_gutter("  stable both-edges \n").unwrap(),
1276            StyleScrollbarGutter::StableBothEdges
1277        );
1278    }
1279
1280    #[test]
1281    fn parse_style_scrollbar_gutter_rejects_empty_garbage_unicode_and_numbers() {
1282        for input in [
1283            "", " ", "\t\n", "none", "auto stable", "auto;", "stable;", "0", "-0", "NaN", "inf",
1284            "9223372036854775807", "\u{1F600}", "stable", "stable\0",
1285        ] {
1286            assert!(
1287                parse_style_scrollbar_gutter(input).is_err(),
1288                "{input:?} unexpectedly parsed"
1289            );
1290        }
1291        let err = parse_style_scrollbar_gutter("  nope  ").unwrap_err();
1292        assert_eq!(
1293            err,
1294            StyleScrollbarGutterParseError::InvalidValue("  nope  ")
1295        );
1296        assert!(format!("{err}").contains("scrollbar-gutter"));
1297    }
1298
1299    #[test]
1300    fn parse_style_scrollbar_gutter_survives_long_and_nested_input() {
1301        let long = "stable ".repeat(200_000);
1302        assert!(parse_style_scrollbar_gutter(&long).is_err());
1303        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1304        assert!(parse_style_scrollbar_gutter(&nested).is_err());
1305    }
1306
1307    // ---------------------------------------------------------------------
1308    // parse_style_overflow_clip_margin
1309    // ---------------------------------------------------------------------
1310
1311    #[test]
1312    fn parse_style_overflow_clip_margin_accepts_either_component_in_either_order() {
1313        // <visual-box> only — length defaults to 0.
1314        let only_box = parse_style_overflow_clip_margin("content-box").unwrap();
1315        assert_eq!(only_box.clip_edge, VisualBox::ContentBox);
1316        assert_eq!(only_box.inner, PixelValue::default());
1317
1318        // <length> only — box defaults to padding-box.
1319        let only_len = parse_style_overflow_clip_margin("20px").unwrap();
1320        assert_eq!(only_len.clip_edge, VisualBox::PaddingBox);
1321        assert_eq!(only_len.inner, PixelValue::const_px(20));
1322
1323        // `||` means either order is valid.
1324        let a = parse_style_overflow_clip_margin("border-box 10px").unwrap();
1325        let b = parse_style_overflow_clip_margin("10px border-box").unwrap();
1326        assert_eq!(a, b);
1327        assert_eq!(a.clip_edge, VisualBox::BorderBox);
1328        assert_eq!(a.inner, PixelValue::const_px(10));
1329
1330        // Interior whitespace is collapsed by split_whitespace.
1331        let c = parse_style_overflow_clip_margin("  border-box \t\n  10px  ").unwrap();
1332        assert_eq!(c, a);
1333
1334        assert_eq!(VisualBox::default(), VisualBox::PaddingBox);
1335    }
1336
1337    #[test]
1338    fn parse_style_overflow_clip_margin_rejects_empty_duplicates_and_garbage() {
1339        for input in [
1340            "",
1341            "   ",
1342            "\t\n",
1343            "content-box content-box", // duplicate box
1344            "10px 20px",               // duplicate length
1345            "content-box 10px 20px",
1346            "content-box padding-box",
1347            "content-box 10px border-box",
1348            "none",
1349            "auto",
1350            "margin-box",
1351            "10px;",
1352            "10 px extra",
1353            "px",
1354            "\u{1F600}",
1355            "10\u{1F600}",
1356            "content-box",
1357            "content_box",
1358            "CONTENT-BOX",
1359        ] {
1360            assert!(
1361                parse_style_overflow_clip_margin(input).is_err(),
1362                "{input:?} unexpectedly parsed"
1363            );
1364        }
1365        let err = parse_style_overflow_clip_margin("  nope  ").unwrap_err();
1366        assert_eq!(
1367            err,
1368            StyleOverflowClipMarginParseError::InvalidValue("  nope  ")
1369        );
1370        assert!(format!("{err}").contains("overflow-clip-margin"));
1371    }
1372
1373    #[test]
1374    fn parse_style_overflow_clip_margin_accepts_out_of_range_lengths() {
1375        // The declared syntax is `<visual-box> || <length [0,∞]>`: negatives and
1376        // percentages are invalid CSS. The parser delegates to parse_pixel_value
1377        // and clamps nothing, so both are accepted. Characterised, not endorsed.
1378        let neg = parse_style_overflow_clip_margin("-5px").unwrap();
1379        assert!(neg.inner.number.get() < 0.0);
1380
1381        let pct = parse_style_overflow_clip_margin("50%").unwrap();
1382        assert_eq!(pct.inner.metric, SizeMetric::Percent);
1383        assert_eq!(pct.inner.number.get(), 50.0);
1384
1385        // Unitless non-zero numbers are also let through (CSS requires a unit).
1386        let unitless = parse_style_overflow_clip_margin("7").unwrap();
1387        assert_eq!(unitless.inner.metric, SizeMetric::Px);
1388        assert_eq!(unitless.inner.number.get(), 7.0);
1389    }
1390
1391    #[test]
1392    fn parse_style_overflow_clip_margin_saturates_nan_and_infinity() {
1393        // Rust's f32 parser accepts "NaN"/"inf", so these reach PixelValue.
1394        // FloatValue stores milli-units in an isize: NaN saturates to 0 and the
1395        // infinities to the isize bounds — no non-finite value can escape into
1396        // layout, which is the property that actually matters.
1397        let nan = parse_style_overflow_clip_margin("NaN").unwrap();
1398        assert!(!nan.inner.number.get().is_nan());
1399        assert_eq!(nan.inner.number.get(), 0.0);
1400
1401        let pos_inf = parse_style_overflow_clip_margin("inf").unwrap();
1402        assert!(pos_inf.inner.number.get().is_finite());
1403        assert!(pos_inf.inner.number.get() > 0.0);
1404
1405        let neg_inf = parse_style_overflow_clip_margin("-inf").unwrap();
1406        assert!(neg_inf.inner.number.get().is_finite());
1407        assert!(neg_inf.inner.number.get() < 0.0);
1408
1409        // A number far beyond f32 range overflows to inf during parsing and
1410        // then saturates the same way.
1411        let huge = format!("{}px", "9".repeat(4096));
1412        let huge = parse_style_overflow_clip_margin(&huge).unwrap();
1413        assert!(huge.inner.number.get().is_finite());
1414
1415        // Sub-milli precision is quantised away rather than rounded up.
1416        let tiny = parse_style_overflow_clip_margin("0.0001px").unwrap();
1417        assert_eq!(tiny.inner.number.get(), 0.0);
1418    }
1419
1420    #[test]
1421    fn parse_style_overflow_clip_margin_survives_long_and_nested_input() {
1422        let long_token = format!("{}px", "a".repeat(1_000_000));
1423        assert!(parse_style_overflow_clip_margin(&long_token).is_err());
1424
1425        let many_tokens = "content-box ".repeat(100_000);
1426        assert!(parse_style_overflow_clip_margin(&many_tokens).is_err());
1427
1428        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1429        assert!(parse_style_overflow_clip_margin(&nested).is_err());
1430    }
1431
1432    #[test]
1433    fn overflow_clip_margin_round_trips_through_print_as_css_value() {
1434        let lengths = [
1435            PixelValue::const_px(12),
1436            PixelValue::px(1.5),
1437            PixelValue::const_em(2),
1438            PixelValue::const_percent(50),
1439            PixelValue::px(-3.25),
1440        ];
1441        for edge in ALL_VISUAL_BOX {
1442            for inner in lengths {
1443                let original = StyleOverflowClipMargin {
1444                    clip_edge: edge,
1445                    inner,
1446                };
1447                let printed = original.print_as_css_value();
1448                let reparsed = parse_style_overflow_clip_margin(&printed).unwrap_or_else(|e| {
1449                    panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
1450                });
1451                assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
1452            }
1453        }
1454    }
1455
1456    #[test]
1457    fn overflow_clip_margin_zero_length_prints_only_the_box_and_forgets_the_unit() {
1458        // A zero length is elided from the printed form, so its unit is lost on
1459        // the way back (0em == 0px semantically, so this is benign — but the
1460        // struct is *not* preserved bit-for-bit, which a naive round-trip
1461        // assertion would trip over).
1462        let zero_em = StyleOverflowClipMargin {
1463            clip_edge: VisualBox::ContentBox,
1464            inner: PixelValue::const_em(0),
1465        };
1466        assert_eq!(zero_em.print_as_css_value(), "content-box");
1467        let back = parse_style_overflow_clip_margin(&zero_em.print_as_css_value()).unwrap();
1468        assert_eq!(back.clip_edge, VisualBox::ContentBox);
1469        assert_eq!(back.inner.number.get(), 0.0);
1470        assert_eq!(back.inner.metric, SizeMetric::Px);
1471        assert_ne!(back, zero_em);
1472
1473        // The all-default value prints as the bare default box.
1474        let default = StyleOverflowClipMargin::default();
1475        assert_eq!(default.print_as_css_value(), "padding-box");
1476        assert_eq!(
1477            parse_style_overflow_clip_margin(&default.print_as_css_value()).unwrap(),
1478            default
1479        );
1480
1481        // padding-box + non-zero length prints only the length.
1482        let padding_len = StyleOverflowClipMargin {
1483            clip_edge: VisualBox::PaddingBox,
1484            inner: PixelValue::const_px(4),
1485        };
1486        assert_eq!(padding_len.print_as_css_value(), "4px");
1487    }
1488
1489    #[test]
1490    fn visual_box_round_trips_through_the_clip_margin_parser() {
1491        for v in ALL_VISUAL_BOX {
1492            let printed = v.print_as_css_value();
1493            let parsed = parse_style_overflow_clip_margin(&printed).unwrap();
1494            assert_eq!(parsed.clip_edge, v, "{printed:?} did not round-trip");
1495        }
1496    }
1497
1498    // ---------------------------------------------------------------------
1499    // parse_clip_edge (private)
1500    // ---------------------------------------------------------------------
1501
1502    #[test]
1503    fn parse_clip_edge_auto_is_ascii_case_insensitive_and_trimmed() {
1504        for input in ["auto", "AUTO", "Auto", "aUtO", "  auto  ", "\tauto\n"] {
1505            assert_eq!(
1506                parse_clip_edge(input).unwrap(),
1507                OptionF32::None,
1508                "{input:?} should be auto"
1509            );
1510        }
1511        // ...but only the whole token: `auto` glued to anything else is invalid.
1512        assert!(parse_clip_edge("auto5").is_err());
1513        assert!(parse_clip_edge("autopx").is_err());
1514        assert!(parse_clip_edge("auto auto").is_err());
1515    }
1516
1517    #[test]
1518    fn parse_clip_edge_silently_discards_the_unit() {
1519        // BUG (characterised): the edge keeps only `PixelValue::number`, so the
1520        // metric is thrown away — `rect(5em, ...)` is treated as 5 *pixels*, and
1521        // percentages (invalid for `clip`) are accepted as raw numbers.
1522        for input in ["5px", "5em", "5rem", "5pt", "5in", "5cm", "5mm", "5vw", "5vh", "5%"] {
1523            assert_eq!(
1524                parse_clip_edge(input).unwrap(),
1525                OptionF32::Some(5.0),
1526                "{input:?} did not collapse to a bare 5.0"
1527            );
1528        }
1529        // A unitless number is accepted as well (CSS requires a unit here).
1530        assert_eq!(parse_clip_edge("5").unwrap(), OptionF32::Some(5.0));
1531        // And whitespace between number and unit is tolerated by the pixel parser.
1532        assert_eq!(parse_clip_edge("5 px").unwrap(), OptionF32::Some(5.0));
1533    }
1534
1535    #[test]
1536    fn parse_clip_edge_quantises_to_thousandths_and_normalises_negative_zero() {
1537        // FloatValue is a fixed-point isize in milli-units: anything below 1/1000
1538        // truncates toward zero rather than rounding.
1539        assert_eq!(parse_clip_edge("0.001px").unwrap(), OptionF32::Some(0.001));
1540        assert_eq!(parse_clip_edge("0.0001px").unwrap(), OptionF32::Some(0.0));
1541        assert_eq!(parse_clip_edge("-0.0009px").unwrap(), OptionF32::Some(0.0));
1542        assert_eq!(parse_clip_edge("1.9999px").unwrap(), OptionF32::Some(1.999));
1543
1544        // -0 loses its sign, so it can never poison downstream sign checks.
1545        let minus_zero = parse_clip_edge("-0px").unwrap().into_option().unwrap();
1546        assert_eq!(minus_zero, 0.0);
1547        assert!(minus_zero.is_sign_positive());
1548
1549        // Negative lengths are explicitly legal for `clip`.
1550        assert_eq!(parse_clip_edge("-10px").unwrap(), OptionF32::Some(-10.0));
1551    }
1552
1553    #[test]
1554    fn parse_clip_edge_saturates_nan_and_infinity_to_finite_values() {
1555        let nan = parse_clip_edge("NaN").unwrap().into_option().unwrap();
1556        assert!(!nan.is_nan(), "NaN must not survive into a clip edge");
1557        assert_eq!(nan, 0.0);
1558
1559        let pos_inf = parse_clip_edge("inf").unwrap().into_option().unwrap();
1560        assert!(pos_inf.is_finite());
1561        assert!(pos_inf > 0.0);
1562
1563        let neg_inf = parse_clip_edge("-infinity").unwrap().into_option().unwrap();
1564        assert!(neg_inf.is_finite());
1565        assert!(neg_inf < 0.0);
1566
1567        let huge = format!("{}px", "9".repeat(4096));
1568        let huge = parse_clip_edge(&huge).unwrap().into_option().unwrap();
1569        assert!(huge.is_finite());
1570    }
1571
1572    #[test]
1573    fn parse_clip_edge_rejects_empty_bare_units_and_garbage() {
1574        for input in [
1575            "",
1576            "   ",
1577            "\t\n",
1578            "px",
1579            "em",
1580            "%",
1581            "abc",
1582            "10px;",
1583            "10px 20px",
1584            "(10px)",
1585            "\0",
1586            "\u{1F600}",
1587            "1px",
1588            "1px\u{0301}",
1589            "0x10",
1590        ] {
1591            assert!(parse_clip_edge(input).is_err(), "{input:?} unexpectedly parsed");
1592        }
1593        // The error carries the *trimmed token*, not the surrounding input.
1594        assert_eq!(
1595            parse_clip_edge("  abc  ").unwrap_err(),
1596            StyleClipRectParseError::InvalidValue("abc")
1597        );
1598    }
1599
1600    // ---------------------------------------------------------------------
1601    // parse_clip_rect
1602    // ---------------------------------------------------------------------
1603
1604    #[test]
1605    fn clip_rect_round_trips_through_print_as_css_value() {
1606        let rects = [
1607            StyleClipRect::default(),
1608            StyleClipRect {
1609                top: OptionF32::Some(0.0),
1610                right: OptionF32::Some(-2.25),
1611                bottom: OptionF32::Some(1.5),
1612                left: OptionF32::None,
1613            },
1614            StyleClipRect {
1615                top: OptionF32::Some(10.0),
1616                right: OptionF32::Some(20.0),
1617                bottom: OptionF32::Some(30.0),
1618                left: OptionF32::Some(40.0),
1619            },
1620            StyleClipRect {
1621                top: OptionF32::None,
1622                right: OptionF32::Some(-1.0),
1623                bottom: OptionF32::None,
1624                left: OptionF32::Some(-1.0),
1625            },
1626        ];
1627        for original in rects {
1628            let printed = original.print_as_css_value();
1629            let reparsed = parse_clip_rect(&printed).unwrap_or_else(|e| {
1630                panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
1631            });
1632            assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
1633        }
1634        assert_eq!(
1635            StyleClipRect::default().print_as_css_value(),
1636            "rect(auto, auto, auto, auto)"
1637        );
1638    }
1639
1640    #[test]
1641    fn parse_clip_rect_accepts_the_auto_comma_and_legacy_space_forms() {
1642        let all_auto = StyleClipRect::default();
1643        for input in [
1644            "auto",
1645            "AUTO",
1646            "  auto  ",
1647            "\u{00A0}auto", // NBSP is Unicode whitespace, so `trim` eats it
1648            "rect(auto, auto, auto, auto)",
1649            "rect(auto auto auto auto)",
1650            "RECT(auto, auto, auto, auto)",
1651            "  rect( auto , auto , auto , auto )  ",
1652        ] {
1653            assert_eq!(
1654                parse_clip_rect(input).unwrap(),
1655                all_auto,
1656                "{input:?} should be all-auto"
1657            );
1658        }
1659
1660        let mixed = parse_clip_rect("rect(1px, auto, -3px, 4px)").unwrap();
1661        assert_eq!(mixed.top, OptionF32::Some(1.0));
1662        assert_eq!(mixed.right, OptionF32::None);
1663        assert_eq!(mixed.bottom, OptionF32::Some(-3.0));
1664        assert_eq!(mixed.left, OptionF32::Some(4.0));
1665
1666        // No space after the commas is fine too.
1667        assert_eq!(
1668            parse_clip_rect("rect(1px,2px,3px,4px)").unwrap(),
1669            StyleClipRect {
1670                top: OptionF32::Some(1.0),
1671                right: OptionF32::Some(2.0),
1672                bottom: OptionF32::Some(3.0),
1673                left: OptionF32::Some(4.0),
1674            }
1675        );
1676    }
1677
1678    #[test]
1679    fn parse_clip_rect_rejects_wrong_arity_mixed_separators_and_trailing_junk() {
1680        for input in [
1681            "rect()",
1682            "rect(,,,)",
1683            "rect(1px)",
1684            "rect(1px, 2px, 3px)",
1685            "rect(1px, 2px, 3px, 4px, 5px)",
1686            "rect(1px, 2px, 3px, 4px,)",
1687            "rect(1px 2px, 3px 4px)", // half comma-separated, half not
1688            "rect(1px 2px 3px)",
1689            "rect(1px 2px 3px 4px 5px)",
1690            "rect(1px, 2px, 3px, 4px",  // no closing paren
1691            "rect 1px, 2px, 3px, 4px)", // no opening paren
1692            "rect (1px, 2px, 3px, 4px)", // space before the paren
1693            "rect(1px, 2px, 3px, 4px) trailing",
1694            "rect(1px, 2px, 3px, 4px);",
1695            "junk rect(1px, 2px, 3px, 4px)",
1696            "rect(auto, auto, auto, abc)",
1697            "",
1698            "   ",
1699            "none",
1700            "inherit",
1701            "0",
1702        ] {
1703            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1704        }
1705    }
1706
1707    #[test]
1708    fn parse_clip_rect_function_name_accepts_only_all_lower_or_all_upper_case() {
1709        // `rect(` and `RECT(` are special-cased; every mixed casing is rejected,
1710        // even though CSS function names are ASCII case-insensitive.
1711        assert!(parse_clip_rect("rect(auto, auto, auto, auto)").is_ok());
1712        assert!(parse_clip_rect("RECT(auto, auto, auto, auto)").is_ok());
1713        for input in [
1714            "Rect(auto, auto, auto, auto)",
1715            "rECT(auto, auto, auto, auto)",
1716            "ReCt(auto, auto, auto, auto)",
1717        ] {
1718            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1719        }
1720    }
1721
1722    #[test]
1723    fn parse_clip_rect_errors_point_at_the_offending_token() {
1724        // A bad *edge* reports just the token...
1725        let err = parse_clip_rect("rect(1px, abc, 3px, 4px)").unwrap_err();
1726        assert_eq!(err, StyleClipRectParseError::InvalidValue("abc"));
1727        let msg = format!("{err}");
1728        assert!(msg.contains("abc"), "{msg}");
1729        // (the message's own "Expected rect(...)" hint aside, none of the *input*
1730        // apart from the bad token is echoed back)
1731        assert!(!msg.contains("1px"), "message leaked the whole input: {msg}");
1732
1733        // ...while a structural error reports the untrimmed input.
1734        let err = parse_clip_rect("  rect(1px)  ").unwrap_err();
1735        assert_eq!(err, StyleClipRectParseError::InvalidValue("  rect(1px)  "));
1736    }
1737
1738    #[test]
1739    fn parse_clip_rect_survives_deep_nesting_and_huge_input() {
1740        // Not a recursive-descent parser, so nesting cannot blow the stack.
1741        let nested = format!("{}{}", "rect(".repeat(10_000), ")".repeat(10_000));
1742        assert!(parse_clip_rect(&nested).is_err());
1743
1744        let parens = format!("{}{}", "(".repeat(100_000), ")".repeat(100_000));
1745        assert!(parse_clip_rect(&parens).is_err());
1746
1747        // 50k edges: rejected on arity, not by hanging.
1748        let wide = format!("rect({})", "1px,".repeat(50_000));
1749        assert!(parse_clip_rect(&wide).is_err());
1750
1751        let long_token = format!("rect({}, auto, auto, auto)", "a".repeat(1_000_000));
1752        assert!(parse_clip_rect(&long_token).is_err());
1753
1754        // A legitimately huge magnitude parses and saturates instead of overflowing.
1755        let huge = format!("rect({}px, auto, auto, auto)", "9".repeat(4096));
1756        let huge = parse_clip_rect(&huge).unwrap();
1757        let top = huge.top.into_option().unwrap();
1758        assert!(top.is_finite());
1759        assert!(top > 0.0);
1760    }
1761
1762    #[test]
1763    fn parse_clip_rect_does_not_panic_on_multibyte_input() {
1764        for input in [
1765            "rect(\u{1F600}, \u{1F600}, \u{1F600}, \u{1F600})",
1766            "rect(1px\u{0301}, auto, auto, auto)",
1767            "réct(1px, 2px, 3px, 4px)",
1768            "rect(1px, auto, auto, auto)", // fullwidth digit
1769            "rect(1px, auto, auto, auto\u{200B})",
1770            "\u{1F600}",
1771            "автo",
1772            "rect(٣px, auto, auto, auto)", // arabic-indic digit
1773        ] {
1774            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1775        }
1776    }
1777
1778    // ---------------------------------------------------------------------
1779    // StyleClipRect::resolve
1780    // ---------------------------------------------------------------------
1781
1782    #[test]
1783    fn clip_rect_default_is_all_auto() {
1784        let d = StyleClipRect::default();
1785        assert_eq!(d.top, OptionF32::None);
1786        assert_eq!(d.right, OptionF32::None);
1787        assert_eq!(d.bottom, OptionF32::None);
1788        assert_eq!(d.left, OptionF32::None);
1789    }
1790
1791    #[test]
1792    fn clip_rect_resolve_expands_auto_edges_to_the_border_box() {
1793        // auto: top/left = 0, bottom/right = the border-box extent.
1794        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1795            100.0, 50.0, // used width / height
1796            1.0, 2.0, 3.0, 4.0, // padding l / r / t / b
1797            5.0, 6.0, 7.0, 8.0, // border  l / r / t / b
1798        );
1799        assert_eq!(top, 0.0);
1800        assert_eq!(left, 0.0);
1801        assert_eq!(right, 100.0 + 1.0 + 2.0 + 5.0 + 6.0);
1802        assert_eq!(bottom, 50.0 + 3.0 + 4.0 + 7.0 + 8.0);
1803    }
1804
1805    #[test]
1806    fn clip_rect_resolve_at_zero_and_with_negative_geometry() {
1807        let all_zero = StyleClipRect::default().resolve(
1808            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1809        );
1810        assert_eq!(all_zero, (0.0, 0.0, 0.0, 0.0));
1811
1812        // Negative geometry is summed as-is (no clamping): deterministic, finite.
1813        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1814            -10.0, -20.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
1815        );
1816        assert_eq!(top, 0.0);
1817        assert_eq!(left, 0.0);
1818        assert_eq!(right, -14.0);
1819        assert_eq!(bottom, -24.0);
1820    }
1821
1822    #[test]
1823    fn clip_rect_resolve_ignores_the_geometry_for_explicit_edges() {
1824        let explicit = StyleClipRect {
1825            top: OptionF32::Some(1.0),
1826            right: OptionF32::Some(2.0),
1827            bottom: OptionF32::Some(3.0),
1828            left: OptionF32::Some(4.0),
1829        };
1830        // Even with hostile geometry the explicit edges come back untouched.
1831        for geometry in [
1832            f32::NAN,
1833            f32::INFINITY,
1834            f32::NEG_INFINITY,
1835            f32::MAX,
1836            f32::MIN,
1837            f32::MIN_POSITIVE,
1838        ] {
1839            let resolved = explicit.resolve(
1840                geometry, geometry, geometry, geometry, geometry, geometry, geometry, geometry,
1841                geometry, geometry,
1842            );
1843            assert_eq!(
1844                resolved,
1845                (1.0, 2.0, 3.0, 4.0),
1846                "explicit edges were perturbed by geometry {geometry:?}"
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn clip_rect_resolve_saturates_at_f32_max_and_keeps_nan_contained() {
1853        // f32::MAX + f32::MAX overflows to +inf rather than panicking.
1854        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1855            f32::MAX,
1856            f32::MAX,
1857            f32::MAX,
1858            f32::MAX,
1859            f32::MAX,
1860            f32::MAX,
1861            f32::MAX,
1862            f32::MAX,
1863            f32::MAX,
1864            f32::MAX,
1865        );
1866        assert_eq!(top, 0.0);
1867        assert_eq!(left, 0.0);
1868        assert!(right.is_infinite() && right.is_sign_positive());
1869        assert!(bottom.is_infinite() && bottom.is_sign_positive());
1870
1871        // NaN geometry propagates into the auto edges only (documented result:
1872        // NaN in, NaN out — no panic, and the fixed edges stay clean).
1873        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1874            f32::NAN,
1875            f32::NAN,
1876            0.0,
1877            0.0,
1878            0.0,
1879            0.0,
1880            0.0,
1881            0.0,
1882            0.0,
1883            0.0,
1884        );
1885        assert_eq!(top, 0.0);
1886        assert_eq!(left, 0.0);
1887        assert!(right.is_nan());
1888        assert!(bottom.is_nan());
1889
1890        // +inf added to -inf is NaN — still no panic.
1891        let (_, right, bottom, _) = StyleClipRect::default().resolve(
1892            f32::INFINITY,
1893            f32::INFINITY,
1894            f32::NEG_INFINITY,
1895            0.0,
1896            f32::NEG_INFINITY,
1897            0.0,
1898            0.0,
1899            0.0,
1900            0.0,
1901            0.0,
1902        );
1903        assert!(right.is_nan());
1904        assert!(bottom.is_nan());
1905    }
1906
1907    // ---------------------------------------------------------------------
1908    // Error types: to_contained / to_shared
1909    // ---------------------------------------------------------------------
1910
1911    /// Payloads that an error may have to carry: empty, whitespace, multibyte,
1912    /// combining marks, an embedded NUL, and a large string.
1913    fn error_payloads() -> Vec<String> {
1914        vec![
1915            String::new(),
1916            String::from(" "),
1917            String::from("bogus"),
1918            String::from("\u{1F600}\u{0301}"),
1919            String::from("a\0b"),
1920            String::from("rect(1px, 2px, 3px, 4px)"),
1921            "x".repeat(100_000),
1922        ]
1923    }
1924
1925    macro_rules! assert_error_round_trips {
1926        ($borrowed:ident) => {{
1927            for payload in error_payloads() {
1928                let borrowed = $borrowed::InvalidValue(payload.as_str());
1929                let owned = borrowed.to_contained();
1930                let back = owned.to_shared();
1931                assert_eq!(
1932                    back, borrowed,
1933                    "{}::InvalidValue({payload:?}) lost data on to_contained/to_shared",
1934                    stringify!($borrowed)
1935                );
1936                // ...and the owned form is stable under a second lap.
1937                assert_eq!(owned.to_shared().to_contained(), owned);
1938            }
1939        }};
1940    }
1941
1942    #[test]
1943    fn parse_errors_round_trip_between_borrowed_and_owned_forms() {
1944        assert_error_round_trips!(LayoutOverflowParseError);
1945        assert_error_round_trips!(StyleScrollbarGutterParseError);
1946        assert_error_round_trips!(StyleOverflowClipMarginParseError);
1947        assert_error_round_trips!(StyleClipRectParseError);
1948    }
1949
1950    #[test]
1951    fn parse_errors_produced_by_the_parsers_round_trip_too() {
1952        let e = parse_layout_overflow("nope").unwrap_err();
1953        assert_eq!(e.to_contained().to_shared(), e);
1954
1955        let e = parse_style_scrollbar_gutter("nope").unwrap_err();
1956        assert_eq!(e.to_contained().to_shared(), e);
1957
1958        let e = parse_style_overflow_clip_margin("nope nope").unwrap_err();
1959        assert_eq!(e.to_contained().to_shared(), e);
1960
1961        let e = parse_clip_rect("rect(nope)").unwrap_err();
1962        assert_eq!(e.to_contained().to_shared(), e);
1963    }
1964
1965    #[test]
1966    fn parse_error_messages_name_the_property_and_quote_the_value() {
1967        let msg = format!("{}", LayoutOverflowParseError::InvalidValue("zzz"));
1968        assert!(msg.contains("overflow") && msg.contains("zzz"), "{msg}");
1969
1970        let msg = format!("{}", StyleScrollbarGutterParseError::InvalidValue("zzz"));
1971        assert!(msg.contains("scrollbar-gutter") && msg.contains("zzz"), "{msg}");
1972
1973        let msg = format!("{}", StyleOverflowClipMarginParseError::InvalidValue("zzz"));
1974        assert!(
1975            msg.contains("overflow-clip-margin") && msg.contains("zzz"),
1976            "{msg}"
1977        );
1978
1979        let msg = format!("{}", StyleClipRectParseError::InvalidValue("zzz"));
1980        assert!(msg.contains("clip") && msg.contains("zzz"), "{msg}");
1981
1982        // Debug is wired to Display: it must not panic on hostile payloads.
1983        let weird = StyleClipRectParseError::InvalidValue("\u{1F600}\0\u{0301}");
1984        assert!(!format!("{weird:?}").is_empty());
1985    }
1986}