Skip to main content

azul_css/props/layout/
overflow.rs

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