Skip to main content

azul_css/props/layout/
dimensions.rs

1//! CSS properties related to dimensions and sizing.
2//!
3//! Key types: [`LayoutWidth`] / [`LayoutHeight`] (support `auto`, pixel values,
4//! `min-content`, `max-content`, `fit-content()`, and `calc()` expressions),
5//! [`LayoutMinWidth`], [`LayoutMinHeight`], [`LayoutMaxWidth`], [`LayoutMaxHeight`]
6//! (simple pixel-value constraints), and [`LayoutBoxSizing`].
7//!
8//! `calc()` expressions use a flat stack-machine representation via [`CalcAstItem`]
9//! — see its documentation for the encoding scheme. The layout solver in
10//! `layout/src/solver3/calc.rs` evaluates these at resolve time.
11
12use alloc::{
13    string::{String, ToString},
14    vec::Vec,
15};
16
17use crate::{
18    impl_option, impl_option_inner, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_eq,
19    impl_vec_hash, impl_vec_mut, impl_vec_ord, impl_vec_partialeq, impl_vec_partialord,
20    props::{
21        basic::pixel::{CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue},
22        formatter::PrintAsCssValue,
23        macros::PixelValueTaker,
24    },
25};
26
27// -- Calc AST --
28#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
29/// A single item in a `calc()` expression, stored as a flat stack-machine representation.
30///
31/// The expression `calc(33.333% - 10px)` is stored as:
32/// ```text
33/// [Value(33.333%), Sub, Value(10px)]
34/// ```
35///
36/// For nested expressions like `calc(100% - (20px + 5%))`:
37/// ```text
38/// [Value(100%), Sub, BraceOpen, Value(20px), Add, Value(5%), BraceClose]
39/// ```
40///
41/// **Resolution**: Walk left to right. When `BraceClose` is hit, resolve everything
42/// back to the matching `BraceOpen`, replace that span with a single `Value`, and continue.
43#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44#[repr(C, u8)]
45pub enum CalcAstItem {
46    /// A literal value (e.g. `10px`, `33.333%`, `2em`)
47    Value(PixelValue),
48    /// `+` operator
49    Add,
50    /// `-` operator
51    Sub,
52    /// `*` operator
53    Mul,
54    /// `/` operator
55    Div,
56    /// `(` — opens a sub-expression
57    BraceOpen,
58    /// `)` — closes a sub-expression; triggers resolution of the inner span
59    BraceClose,
60}
61
62/// C-compatible `Vec<CalcAstItem>` for FFI interop.
63impl_vec!(
64    CalcAstItem,
65    CalcAstItemVec,
66    CalcAstItemVecDestructor,
67    CalcAstItemVecDestructorType,
68    CalcAstItemVecSlice,
69    OptionCalcAstItem
70);
71impl_vec_clone!(CalcAstItem, CalcAstItemVec, CalcAstItemVecDestructor);
72impl_vec_debug!(CalcAstItem, CalcAstItemVec);
73impl_vec_partialeq!(CalcAstItem, CalcAstItemVec);
74impl_vec_eq!(CalcAstItem, CalcAstItemVec);
75impl_vec_partialord!(CalcAstItem, CalcAstItemVec);
76impl_vec_ord!(CalcAstItem, CalcAstItemVec);
77impl_vec_hash!(CalcAstItem, CalcAstItemVec);
78impl_vec_mut!(CalcAstItem, CalcAstItemVec);
79
80impl_option!(
81    CalcAstItem,
82    OptionCalcAstItem,
83    copy = false,
84    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
85);
86
87/// Parse a `calc()` inner expression (the part between the parentheses) into
88/// a flat `CalcAstItemVec` suitable for stack-machine evaluation.
89///
90/// Examples:
91/// - `"100% - 20px"` → `[Value(100%), Sub, Value(20px)]`
92/// - `"(100% - 20px) / 3"` → `[BraceOpen, Value(100%), Sub, Value(20px), BraceClose, Div, Value(3)]`
93///
94/// **Tokenisation rules**:
95///  - Whitespace is skipped between tokens.
96///  - `+`, `-`, `*`, `/` are operators (but `-` at the start of a number is
97///    part of the number literal, e.g. `-10px`).
98///  - `(` / `)` produce `BraceOpen` / `BraceClose`.
99///  - Anything else is parsed as a `PixelValue` via `parse_pixel_value`.
100#[cfg(feature = "parser")]
101fn parse_calc_expression(input: &str) -> Result<CalcAstItemVec, ()> {
102    use crate::props::basic::pixel::parse_pixel_value;
103
104    let mut items: Vec<CalcAstItem> = Vec::new();
105    let input = input.trim();
106    let bytes = input.as_bytes();
107    let mut i = 0;
108
109    while i < bytes.len() {
110        // Skip whitespace
111        if bytes[i].is_ascii_whitespace() {
112            i += 1;
113            continue;
114        }
115
116        match bytes[i] {
117            b'+' => { items.push(CalcAstItem::Add); i += 1; }
118            b'*' => { items.push(CalcAstItem::Mul); i += 1; }
119            b'/' => { items.push(CalcAstItem::Div); i += 1; }
120            b'(' => { items.push(CalcAstItem::BraceOpen); i += 1; }
121            b')' => { items.push(CalcAstItem::BraceClose); i += 1; }
122            b'-' => {
123                // Decide: is this a subtraction operator or a negative number?
124                // It's a negative number if:
125                //   - it's the first token, OR
126                //   - the previous token is an operator or BraceOpen
127                let is_negative_number = items.is_empty()
128                    || matches!(
129                        items.last(),
130                        Some(CalcAstItem::Add | CalcAstItem::Sub | CalcAstItem::Mul | CalcAstItem::Div
131| CalcAstItem::BraceOpen)
132                    );
133
134                if is_negative_number {
135                    // Parse as negative number value
136                    let rest = &input[i..];
137                    let end = find_value_end(rest);
138                    if end == 0 { return Err(()); }
139                    let val_str = &rest[..end];
140                    let pv = parse_pixel_value(val_str).map_err(|_| ())?;
141                    items.push(CalcAstItem::Value(pv));
142                    i += end;
143                } else {
144                    items.push(CalcAstItem::Sub);
145                    i += 1;
146                }
147            }
148            _ => {
149                // Must be a numeric value (e.g. 100%, 20px, 3, 1.5em)
150                let rest = &input[i..];
151                let end = find_value_end(rest);
152                if end == 0 { return Err(()); }
153                let val_str = &rest[..end];
154                let pv = parse_pixel_value(val_str).map_err(|_| ())?;
155                items.push(CalcAstItem::Value(pv));
156                i += end;
157            }
158        }
159    }
160
161    if items.is_empty() {
162        return Err(());
163    }
164
165    Ok(CalcAstItemVec::from(items))
166}
167
168/// Find the end of a numeric value token in a `calc()` expression.
169/// Returns the byte offset where the value ends.
170#[cfg(feature = "parser")]
171fn find_value_end(s: &str) -> usize {
172    let bytes = s.as_bytes();
173    let mut i = 0;
174
175    // Optional leading sign
176    if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
177        i += 1;
178    }
179
180    // Digits and decimal point
181    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
182        i += 1;
183    }
184
185    // Unit suffix (alphabetic characters like px, %, em, rem, vw, vh, etc.)
186    while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'%') {
187        i += 1;
188    }
189
190    i
191}
192
193/// Format a `CalcAstItemVec` as a CSS `calc(...)` string.
194fn calc_ast_to_css_string(items: &CalcAstItemVec) -> String {
195    let inner: Vec<String> = items.iter().map(|i| match i {
196        CalcAstItem::Value(v) => v.to_string(),
197        CalcAstItem::Add => "+".to_string(),
198        CalcAstItem::Sub => "-".to_string(),
199        CalcAstItem::Mul => "*".to_string(),
200        CalcAstItem::Div => "/".to_string(),
201        CalcAstItem::BraceOpen => "(".to_string(),
202        CalcAstItem::BraceClose => ")".to_string(),
203    }).collect();
204    alloc::format!("calc({})", inner.join(" "))
205}
206
207// -- Type Definitions --
208
209macro_rules! define_dimension_property {
210    ($struct_name:ident, $default_fn:expr) => {
211        #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
212        #[repr(C)]
213        pub struct $struct_name {
214            pub inner: PixelValue,
215        }
216
217        impl Default for $struct_name {
218            fn default() -> Self {
219                $default_fn()
220            }
221        }
222
223        impl PixelValueTaker for $struct_name {
224            fn from_pixel_value(inner: PixelValue) -> Self {
225                Self { inner }
226            }
227        }
228
229        impl_pixel_value!($struct_name);
230
231        impl PrintAsCssValue for $struct_name {
232            fn print_as_css_value(&self) -> String {
233                self.inner.to_string()
234            }
235        }
236    };
237}
238
239macro_rules! define_sizing_enum {
240    ($name:ident) => {
241        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
242        #[repr(C, u8)]
243        #[derive(Default)]
244        pub enum $name {
245            #[default]
246            Auto,
247            Px(PixelValue),
248            MinContent,
249            MaxContent,
250            /// `fit-content(<length-percentage>)` = `min(max-content, max(min-content, <length-percentage>))`
251            FitContent(PixelValue),
252            /// `calc()` expression stored as a flat stack-machine AST
253            Calc(CalcAstItemVec),
254        }
255
256        impl PixelValueTaker for $name {
257            fn from_pixel_value(inner: PixelValue) -> Self {
258                $name::Px(inner)
259            }
260        }
261
262        impl PrintAsCssValue for $name {
263            fn print_as_css_value(&self) -> String {
264                match self {
265                    $name::Auto => "auto".to_string(),
266                    $name::Px(v) => v.to_string(),
267                    $name::MinContent => "min-content".to_string(),
268                    $name::MaxContent => "max-content".to_string(),
269                    $name::FitContent(v) => alloc::format!("fit-content({})", v),
270                    $name::Calc(items) => calc_ast_to_css_string(items),
271                }
272            }
273        }
274
275        impl $name {
276            #[must_use] pub fn px(value: f32) -> Self {
277                $name::Px(PixelValue::px(value))
278            }
279
280            #[must_use] pub const fn const_px(value: isize) -> Self {
281                $name::Px(PixelValue::const_px(value))
282            }
283
284            #[must_use] pub fn interpolate(&self, other: &Self, t: f32) -> Self {
285                match (self, other) {
286                    ($name::Px(a), $name::Px(b)) => $name::Px(a.interpolate(b, t)),
287                    ($name::FitContent(a), $name::FitContent(b)) => $name::FitContent(a.interpolate(b, t)),
288                    (_, $name::Px(b)) if t >= 0.5 => $name::Px(*b),
289                    ($name::Px(a), _) if t < 0.5 => $name::Px(*a),
290                    ($name::Auto, $name::Auto) => $name::Auto,
291                    (a, _) if t < 0.5 => a.clone(),
292                    (_, b) => b.clone(),
293                }
294            }
295        }
296    };
297}
298
299define_sizing_enum!(LayoutWidth);
300define_sizing_enum!(LayoutHeight);
301
302/// CSS `min-width` property. Defaults to `0px`.
303define_dimension_property!(LayoutMinWidth, || Self {
304    inner: PixelValue::zero()
305});
306/// CSS `min-height` property. Defaults to `0px`.
307define_dimension_property!(LayoutMinHeight, || Self {
308    inner: PixelValue::zero()
309});
310/// CSS `max-width` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
311///
312/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
313/// padding/margin to this sentinel would overflow to infinity.
314define_dimension_property!(LayoutMaxWidth, || Self {
315    inner: PixelValue::px(core::f32::MAX)
316});
317/// CSS `max-height` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
318///
319/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
320/// padding/margin to this sentinel would overflow to infinity.
321define_dimension_property!(LayoutMaxHeight, || Self {
322    inner: PixelValue::px(core::f32::MAX)
323});
324
325/// Represents a `box-sizing` attribute
326#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
327#[repr(C)]
328#[derive(Default)]
329pub enum LayoutBoxSizing {
330    #[default]
331    ContentBox,
332    BorderBox,
333}
334
335
336impl PrintAsCssValue for LayoutBoxSizing {
337    fn print_as_css_value(&self) -> String {
338        String::from(match self {
339            Self::ContentBox => "content-box",
340            Self::BorderBox => "border-box",
341        })
342    }
343}
344
345// -- Parser --
346
347#[cfg(feature = "parser")]
348pub mod parser {
349
350    use alloc::string::ToString;
351    use crate::corety::AzString;
352
353    #[allow(clippy::wildcard_imports)] // parser submodule reuses the parent module's value types
354    use super::*;
355    use crate::props::basic::pixel::parse_pixel_value;
356
357    macro_rules! define_pixel_dimension_parser {
358        ($fn_name:ident, $struct_name:ident, $error_name:ident, $error_owned_name:ident) => {
359            #[derive(Clone, PartialEq, Eq)]
360            pub enum $error_name<'a> {
361                PixelValue(CssPixelValueParseError<'a>),
362            }
363
364            impl_debug_as_display!($error_name<'a>);
365            impl_display! { $error_name<'a>, {
366                PixelValue(e) => format!("{}", e),
367            }}
368
369            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
370
371            #[derive(Debug, Clone, PartialEq, Eq)]
372            #[repr(C, u8)]
373            pub enum $error_owned_name {
374                PixelValue(CssPixelValueParseErrorOwned),
375            }
376
377            impl $error_name<'_> {
378                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
379                    match self {
380                        $error_name::PixelValue(e) => {
381                            $error_owned_name::PixelValue(e.to_contained())
382                        }
383                    }
384                }
385            }
386
387            impl $error_owned_name {
388                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
389                    match self {
390                        $error_owned_name::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
391                    }
392                }
393            }
394
395            /// # Errors
396            ///
397            /// Returns an error if `input` is not a valid CSS value for this property.
398            pub fn $fn_name(input: &str) -> Result<$struct_name, $error_name<'_>> {
399                parse_pixel_value(input)
400                    .map(|v| $struct_name { inner: v })
401                    .map_err($error_name::PixelValue)
402            }
403        };
404    }
405
406    macro_rules! define_sizing_parser {
407        ($fn_name:ident, $enum_name:ident, $error_name:ident, $error_owned_name:ident, $keyword_label:expr) => {
408            #[derive(Clone, PartialEq, Eq)]
409            pub enum $error_name<'a> {
410                PixelValue(CssPixelValueParseError<'a>),
411                InvalidKeyword(&'a str),
412            }
413
414            impl_debug_as_display!($error_name<'a>);
415            impl_display! { $error_name<'a>, {
416                PixelValue(e) => format!("{}", e),
417                InvalidKeyword(k) => format!("Invalid {} keyword: \"{}\"", $keyword_label, k),
418            }}
419
420            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
421
422            #[derive(Debug, Clone, PartialEq, Eq)]
423            #[repr(C, u8)]
424            pub enum $error_owned_name {
425                PixelValue(CssPixelValueParseErrorOwned),
426                InvalidKeyword(AzString),
427            }
428
429            impl $error_name<'_> {
430                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
431                    match self {
432                        $error_name::PixelValue(e) => {
433                            $error_owned_name::PixelValue(e.to_contained())
434                        }
435                        $error_name::InvalidKeyword(k) => {
436                            $error_owned_name::InvalidKeyword(k.to_string().into())
437                        }
438                    }
439                }
440            }
441
442            impl $error_owned_name {
443                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
444                    match self {
445                        $error_owned_name::PixelValue(e) => {
446                            $error_name::PixelValue(e.to_shared())
447                        }
448                        $error_owned_name::InvalidKeyword(k) => {
449                            $error_name::InvalidKeyword(k)
450                        }
451                    }
452                }
453            }
454
455            /// # Errors
456            ///
457            /// Returns an error if `input` is not a valid CSS value for this property.
458            pub fn $fn_name(
459                input: &str,
460            ) -> Result<$enum_name, $error_name<'_>> {
461                let trimmed = input.trim();
462                match trimmed {
463                    "auto" => Ok($enum_name::Auto),
464                    "min-content" => Ok($enum_name::MinContent),
465                    "max-content" => Ok($enum_name::MaxContent),
466                    s if s.starts_with("fit-content(") && s.ends_with(')') => {
467                        let inner = &s[12..s.len() - 1].trim();
468                        parse_pixel_value(inner)
469                            .map(|pv| {
470                                if pv.number.get() < 0.0 {
471                                    $enum_name::FitContent(PixelValue::zero())
472                                } else {
473                                    $enum_name::FitContent(pv)
474                                }
475                            })
476                            .map_err($error_name::PixelValue)
477                    }
478                    s if s.starts_with("calc(") && s.ends_with(')') => {
479                        let inner = &s[5..s.len() - 1];
480                        parse_calc_expression(inner)
481                            .map($enum_name::Calc)
482                            .map_err(|_| $error_name::InvalidKeyword(input))
483                    }
484                    _ => parse_pixel_value(trimmed)
485                        .map($enum_name::Px)
486                        .map_err($error_name::PixelValue),
487                }
488            }
489        };
490    }
491
492    define_sizing_parser!(parse_layout_width, LayoutWidth, LayoutWidthParseError, LayoutWidthParseErrorOwned, "width");
493    define_sizing_parser!(parse_layout_height, LayoutHeight, LayoutHeightParseError, LayoutHeightParseErrorOwned, "height");
494    define_pixel_dimension_parser!(
495        parse_layout_min_width,
496        LayoutMinWidth,
497        LayoutMinWidthParseError,
498        LayoutMinWidthParseErrorOwned
499    );
500    define_pixel_dimension_parser!(
501        parse_layout_min_height,
502        LayoutMinHeight,
503        LayoutMinHeightParseError,
504        LayoutMinHeightParseErrorOwned
505    );
506    define_pixel_dimension_parser!(
507        parse_layout_max_width,
508        LayoutMaxWidth,
509        LayoutMaxWidthParseError,
510        LayoutMaxWidthParseErrorOwned
511    );
512    define_pixel_dimension_parser!(
513        parse_layout_max_height,
514        LayoutMaxHeight,
515        LayoutMaxHeightParseError,
516        LayoutMaxHeightParseErrorOwned
517    );
518
519    // -- Box Sizing Parser --
520
521    #[derive(Clone, PartialEq, Eq)]
522    pub enum LayoutBoxSizingParseError<'a> {
523        InvalidValue(&'a str),
524    }
525
526    impl_debug_as_display!(LayoutBoxSizingParseError<'a>);
527    impl_display! { LayoutBoxSizingParseError<'a>, {
528        InvalidValue(v) => format!("Invalid box-sizing value: \"{}\"", v),
529    }}
530
531    #[derive(Debug, Clone, PartialEq, Eq)]
532    #[repr(C, u8)]
533    pub enum LayoutBoxSizingParseErrorOwned {
534        InvalidValue(AzString),
535    }
536
537    impl LayoutBoxSizingParseError<'_> {
538        #[must_use] pub fn to_contained(&self) -> LayoutBoxSizingParseErrorOwned {
539            match self {
540                LayoutBoxSizingParseError::InvalidValue(s) => {
541                    LayoutBoxSizingParseErrorOwned::InvalidValue((*s).to_string().into())
542                }
543            }
544        }
545    }
546
547    impl LayoutBoxSizingParseErrorOwned {
548        #[must_use] pub fn to_shared(&self) -> LayoutBoxSizingParseError<'_> {
549            match self {
550                Self::InvalidValue(s) => {
551                    LayoutBoxSizingParseError::InvalidValue(s)
552                }
553            }
554        }
555    }
556
557    /// # Errors
558    ///
559    /// Returns an error if `input` is not a valid CSS `box-sizing` value.
560    pub fn parse_layout_box_sizing(
561        input: &str,
562    ) -> Result<LayoutBoxSizing, LayoutBoxSizingParseError<'_>> {
563        match input.trim() {
564            "content-box" => Ok(LayoutBoxSizing::ContentBox),
565            "border-box" => Ok(LayoutBoxSizing::BorderBox),
566            other => Err(LayoutBoxSizingParseError::InvalidValue(other)),
567        }
568    }
569}
570
571#[cfg(feature = "parser")]
572pub use self::parser::*;
573
574#[cfg(all(test, feature = "parser"))]
575mod tests {
576    use super::*;
577    use crate::props::basic::pixel::PixelValue;
578
579    #[test]
580    fn test_parse_layout_width() {
581        assert_eq!(
582            parse_layout_width("150px").unwrap(),
583            LayoutWidth::Px(PixelValue::px(150.0))
584        );
585        assert_eq!(
586            parse_layout_width("2.5em").unwrap(),
587            LayoutWidth::Px(PixelValue::em(2.5))
588        );
589        assert_eq!(
590            parse_layout_width("75%").unwrap(),
591            LayoutWidth::Px(PixelValue::percent(75.0))
592        );
593        assert_eq!(
594            parse_layout_width("0").unwrap(),
595            LayoutWidth::Px(PixelValue::px(0.0))
596        );
597        assert_eq!(
598            parse_layout_width("  100pt  ").unwrap(),
599            LayoutWidth::Px(PixelValue::pt(100.0))
600        );
601        assert_eq!(
602            parse_layout_width("min-content").unwrap(),
603            LayoutWidth::MinContent
604        );
605        assert_eq!(
606            parse_layout_width("max-content").unwrap(),
607            LayoutWidth::MaxContent
608        );
609    }
610
611    #[test]
612    fn test_parse_layout_height_invalid() {
613        // "auto" is now a valid value for height (CSS spec)
614        assert!(parse_layout_height("auto").is_ok());
615        // Liberal parsing accepts whitespace between number and unit
616        assert!(parse_layout_height("150 px").is_ok());
617        assert!(parse_layout_height("px").is_err());
618        assert!(parse_layout_height("invalid").is_err());
619    }
620
621    #[test]
622    fn test_parse_layout_box_sizing() {
623        assert_eq!(
624            parse_layout_box_sizing("content-box").unwrap(),
625            LayoutBoxSizing::ContentBox
626        );
627        assert_eq!(
628            parse_layout_box_sizing("border-box").unwrap(),
629            LayoutBoxSizing::BorderBox
630        );
631        assert_eq!(
632            parse_layout_box_sizing("  border-box  ").unwrap(),
633            LayoutBoxSizing::BorderBox
634        );
635    }
636
637    #[test]
638    fn test_parse_layout_box_sizing_invalid() {
639        assert!(parse_layout_box_sizing("padding-box").is_err());
640        assert!(parse_layout_box_sizing("borderbox").is_err());
641        assert!(parse_layout_box_sizing("").is_err());
642    }
643}
644
645#[cfg(all(test, feature = "parser"))]
646mod autotest_generated {
647    #[allow(clippy::wildcard_imports)]
648    use super::*;
649    use alloc::{
650        format,
651        string::{String, ToString},
652        vec,
653        vec::Vec,
654    };
655
656    /// Maps a `CalcAstItem` to a discriminant tag, so tests can compare the *shape*
657    /// of two ASTs without depending on `FloatValue`'s 1/1000 quantisation.
658    const fn tag(item: &CalcAstItem) -> u8 {
659        match item {
660            CalcAstItem::Value(_) => 0,
661            CalcAstItem::Add => 1,
662            CalcAstItem::Sub => 2,
663            CalcAstItem::Mul => 3,
664            CalcAstItem::Div => 4,
665            CalcAstItem::BraceOpen => 5,
666            CalcAstItem::BraceClose => 6,
667        }
668    }
669
670    fn shape(items: &CalcAstItemVec) -> Vec<u8> {
671        items.iter().map(tag).collect()
672    }
673
674    fn calc_items(w: &LayoutWidth) -> Vec<CalcAstItem> {
675        match w {
676            LayoutWidth::Calc(items) => items.as_slice().to_vec(),
677            other => panic!("expected LayoutWidth::Calc, got {other:?}"),
678        }
679    }
680
681    fn shape_of_width(w: &LayoutWidth) -> Vec<u8> {
682        calc_items(w).iter().map(tag).collect()
683    }
684
685    // ---------------------------------------------------------------------
686    // parse_calc_expression — malformed / boundary / unicode
687    // ---------------------------------------------------------------------
688
689    #[test]
690    fn calc_empty_and_whitespace_only_input_is_err() {
691        assert!(parse_calc_expression("").is_err());
692        assert!(parse_calc_expression("   ").is_err());
693        assert!(parse_calc_expression("\t\n\r ").is_err());
694    }
695
696    #[test]
697    fn calc_garbage_input_is_err_never_panics() {
698        for garbage in [
699            "???", "@@@", "px", "em", "%", "#", "1px;", "abc", "!!!", "\0", "\u{7f}", ",", ";",
700            "1,2", "10 px 20 %%", "--", "-", "-.", "1..px", "1.2.3px",
701        ] {
702            assert!(
703                parse_calc_expression(garbage).is_err(),
704                "expected Err for {garbage:?}"
705            );
706        }
707    }
708
709    #[test]
710    fn calc_valid_minimal_matches_documented_ast() {
711        // Positive control, straight out of the doc comment on `parse_calc_expression`.
712        let parsed = parse_calc_expression("100% - 20px").unwrap();
713        let expected = vec![
714            CalcAstItem::Value(PixelValue::percent(100.0)),
715            CalcAstItem::Sub,
716            CalcAstItem::Value(PixelValue::px(20.0)),
717        ];
718        assert_eq!(parsed.as_slice(), expected.as_slice());
719    }
720
721    #[test]
722    fn calc_documented_nested_example_parses_exactly() {
723        let parsed = parse_calc_expression("(100% - 20px) / 3").unwrap();
724        let expected = vec![
725            CalcAstItem::BraceOpen,
726            CalcAstItem::Value(PixelValue::percent(100.0)),
727            CalcAstItem::Sub,
728            CalcAstItem::Value(PixelValue::px(20.0)),
729            CalcAstItem::BraceClose,
730            CalcAstItem::Div,
731            // A bare `3` is a unit-less number and becomes `px`.
732            CalcAstItem::Value(PixelValue::px(3.0)),
733        ];
734        assert_eq!(parsed.as_slice(), expected.as_slice());
735    }
736
737    #[test]
738    fn calc_minus_disambiguates_between_sub_and_negative_literal() {
739        // Leading `-` is part of the literal.
740        assert_eq!(
741            parse_calc_expression("-10px").unwrap().as_slice(),
742            [CalcAstItem::Value(PixelValue::px(-10.0))].as_slice()
743        );
744        // `-` after an operator is part of the literal.
745        assert_eq!(
746            parse_calc_expression("100% * -2").unwrap().as_slice(),
747            [
748                CalcAstItem::Value(PixelValue::percent(100.0)),
749                CalcAstItem::Mul,
750                CalcAstItem::Value(PixelValue::px(-2.0)),
751            ]
752            .as_slice()
753        );
754        // `-` after `(` is part of the literal.
755        assert_eq!(
756            parse_calc_expression("(-5px)").unwrap().as_slice(),
757            [
758                CalcAstItem::BraceOpen,
759                CalcAstItem::Value(PixelValue::px(-5.0)),
760                CalcAstItem::BraceClose,
761            ]
762            .as_slice()
763        );
764        // `-` after a value is subtraction — even when written as `5px -10px`, so a
765        // whitespace-separated negative literal silently becomes a subtraction.
766        assert_eq!(
767            parse_calc_expression("5px -10px").unwrap().as_slice(),
768            [
769                CalcAstItem::Value(PixelValue::px(5.0)),
770                CalcAstItem::Sub,
771                CalcAstItem::Value(PixelValue::px(10.0)),
772            ]
773            .as_slice()
774        );
775        // `-` after `)` is subtraction.
776        assert_eq!(
777            shape(&parse_calc_expression("(1px) - 2px").unwrap()),
778            vec![5, 0, 6, 2, 0]
779        );
780    }
781
782    #[test]
783    fn calc_leading_minus_followed_by_space_is_rejected() {
784        // `- 10px` at the start is treated as a negative literal `-`, which fails to parse.
785        assert!(parse_calc_expression("- 10px").is_err());
786        assert!(parse_calc_expression("(- 10px)").is_err());
787    }
788
789    #[test]
790    fn calc_unicode_input_is_rejected_without_panic() {
791        // Every one of these puts a multi-byte char where the tokeniser slices `&input[i..]`;
792        // if `find_value_end` ever returned a non-char-boundary offset this would panic.
793        for input in [
794            "\u{1F600}",              // emoji
795            "100px\u{1F600}",         // emoji after a valid token
796            "10px\u{0301}",           // combining acute accent
797            "10px\u{00A0}- 5px",      // non-breaking space is NOT ascii whitespace
798            "\u{FF11}\u{FF10}px",     // full-width digits
799            "100%",                // full-width digits + ascii percent
800            "π",
801            "10\u{2212}5",            // U+2212 MINUS SIGN, not ASCII '-'
802            "\u{202E}10px",           // RTL override
803            "e\u{0301}m",
804        ] {
805            assert!(
806                parse_calc_expression(input).is_err(),
807                "expected Err for {input:?}"
808            );
809        }
810    }
811
812    #[test]
813    fn calc_nan_literal_is_accepted_but_coerced_to_zero() {
814        // ADVERSARIAL: `parse_pixel_value` delegates to `f32::from_str`, which happily
815        // parses "NaN". The value survives into the AST — but `FloatValue::new` casts
816        // `NaN * 1000.0` to isize, and `as isize` maps NaN to 0. So no NaN ever reaches
817        // the layout solver; the expression silently means `calc(0px)` instead of failing.
818        let parsed = parse_calc_expression("NaN").unwrap();
819        match parsed.get(0).unwrap() {
820            CalcAstItem::Value(v) => {
821                assert!(!v.number.get().is_nan(), "NaN must not survive into the AST");
822                assert_eq!(v.number.get(), 0.0);
823            }
824            other => panic!("expected a Value, got {other:?}"),
825        }
826    }
827
828    #[test]
829    fn calc_huge_and_infinite_literals_saturate_to_a_finite_value() {
830        // "inf" and out-of-range literals parse to f32::INFINITY, which `FloatValue::new`
831        // saturates to isize::MAX. Assert the AST never carries a non-finite number.
832        let huge = "9".repeat(50); // ~1e50, far past f32::MAX
833        for input in [
834            "inf",
835            "-inf",
836            huge.as_str(),
837            "9223372036854775807", // i64::MAX
838            "-9223372036854775808",
839            "340282350000000000000000000000000000000px", // f32::MAX
840        ] {
841            let parsed = parse_calc_expression(input)
842                .unwrap_or_else(|()| panic!("expected Ok for {input:?}"));
843            match parsed.get(0).unwrap() {
844                CalcAstItem::Value(v) => {
845                    let n = v.number.get();
846                    assert!(n.is_finite(), "{input:?} produced a non-finite value: {n}");
847                }
848                other => panic!("expected a Value for {input:?}, got {other:?}"),
849            }
850        }
851    }
852
853    #[test]
854    fn calc_zero_and_negative_zero() {
855        for input in ["0", "-0", "0px", "-0px", "0%"] {
856            let parsed = parse_calc_expression(input).unwrap();
857            match parsed.get(0).unwrap() {
858                CalcAstItem::Value(v) => assert_eq!(
859                    v.number.get(),
860                    0.0,
861                    "{input:?} should quantise to exactly zero"
862                ),
863                other => panic!("expected a Value for {input:?}, got {other:?}"),
864            }
865        }
866        // -0.0 is normalised to +0.0 by the isize round-trip, so it never prints as "-0".
867        assert_eq!(
868            calc_ast_to_css_string(&parse_calc_expression("-0px").unwrap()),
869            "calc(0px)"
870        );
871    }
872
873    #[test]
874    fn calc_sub_millisecond_precision_is_quantised_to_zero() {
875        // FloatValue keeps 3 decimal places; anything below 0.001 collapses to 0.
876        let parsed = parse_calc_expression("0.0005px").unwrap();
877        match parsed.get(0).unwrap() {
878            CalcAstItem::Value(v) => assert_eq!(v.number.get(), 0.0),
879            other => panic!("expected a Value, got {other:?}"),
880        }
881        let parsed = parse_calc_expression("0.001px").unwrap();
882        match parsed.get(0).unwrap() {
883            CalcAstItem::Value(v) => assert!((v.number.get() - 0.001).abs() < 1e-6),
884            other => panic!("expected a Value, got {other:?}"),
885        }
886    }
887
888    #[test]
889    fn calc_deeply_nested_braces_do_not_stack_overflow() {
890        // The tokeniser is iterative, so 10_000 levels of nesting must not blow the stack.
891        const DEPTH: usize = 10_000;
892        let input = format!("{}1px{}", "(".repeat(DEPTH), ")".repeat(DEPTH));
893        let parsed = parse_calc_expression(&input).unwrap();
894        assert_eq!(parsed.len(), DEPTH * 2 + 1);
895        assert_eq!(*parsed.get(0).unwrap(), CalcAstItem::BraceOpen);
896        assert_eq!(
897            *parsed.get(parsed.len() - 1).unwrap(),
898            CalcAstItem::BraceClose
899        );
900        // Printing the same AST must also stay iterative.
901        let printed = calc_ast_to_css_string(&parsed);
902        assert_eq!(printed.matches('(').count(), DEPTH + 1); // + the "calc(" paren
903        assert_eq!(printed.matches(')').count(), DEPTH + 1);
904    }
905
906    #[test]
907    fn calc_unbalanced_braces_are_accepted_without_validation() {
908        // ADVERSARIAL / documents current behaviour: the tokeniser performs NO grammar
909        // validation, so structurally meaningless expressions parse to Ok(..). Anything
910        // that consumes a CalcAstItemVec (the solver in layout/src/solver3/calc.rs) must
911        // therefore be robust against unbalanced braces and dangling operators.
912        assert_eq!(
913            shape(&parse_calc_expression("(((").unwrap()),
914            vec![5, 5, 5]
915        );
916        assert_eq!(shape(&parse_calc_expression(")))").unwrap()), vec![6, 6, 6]);
917        assert_eq!(shape(&parse_calc_expression(")1px(").unwrap()), vec![6, 0, 5]);
918
919        // The same holds through the public parser: `width: calc(()` is accepted.
920        assert_eq!(shape_of_width(&parse_layout_width("calc(()").unwrap()), vec![5]);
921        assert_eq!(
922            shape_of_width(&parse_layout_width("calc()))").unwrap()),
923            vec![6, 6]
924        );
925    }
926
927    #[test]
928    fn calc_dangling_operators_and_missing_operands_are_accepted() {
929        // Same story as the braces: operators with no operands still yield Ok(..).
930        assert_eq!(shape(&parse_calc_expression("+").unwrap()), vec![1]);
931        assert_eq!(shape(&parse_calc_expression("*/").unwrap()), vec![3, 4]);
932        assert_eq!(shape(&parse_calc_expression("1px 2px").unwrap()), vec![0, 0]);
933        assert_eq!(
934            shape(&parse_calc_expression("1px + + 2px").unwrap()),
935            vec![0, 1, 1, 0]
936        );
937    }
938
939    #[test]
940    fn calc_extremely_long_expression_terminates() {
941        // 50_000 terms — the tokeniser is O(n), so this must not hang.
942        const TERMS: usize = 50_000;
943        let mut input = String::from("1px");
944        for _ in 0..TERMS {
945            input.push_str(" + 1px");
946        }
947        let parsed = parse_calc_expression(&input).unwrap();
948        assert_eq!(parsed.len(), TERMS * 2 + 1);
949    }
950
951    #[test]
952    fn calc_extremely_long_garbage_token_is_err() {
953        let long_alpha = "a".repeat(100_000);
954        assert!(parse_calc_expression(&long_alpha).is_err());
955
956        // A 100k-digit literal overflows f32 to +inf, which then saturates to a finite
957        // FloatValue — it must not hang, panic, or produce inf.
958        let long_digits = "1".repeat(100_000);
959        let parsed = parse_calc_expression(&long_digits).unwrap();
960        match parsed.get(0).unwrap() {
961            CalcAstItem::Value(v) => assert!(v.number.get().is_finite()),
962            other => panic!("expected a Value, got {other:?}"),
963        }
964    }
965
966    #[test]
967    fn calc_leading_and_trailing_junk_is_handled_deterministically() {
968        // Surrounding whitespace is trimmed...
969        assert_eq!(
970            parse_calc_expression("  100% - 20px  ").unwrap().as_slice(),
971            parse_calc_expression("100% - 20px").unwrap().as_slice()
972        );
973        // ...but real trailing junk is rejected.
974        assert!(parse_calc_expression("100% - 20px;").is_err());
975        assert!(parse_calc_expression("100% - 20px garbage").is_err());
976        assert!(parse_calc_expression(";100% - 20px").is_err());
977    }
978
979    #[test]
980    fn calc_scientific_notation_is_rejected() {
981        // `find_value_end` stops the digit scan at 'e' and then eats it as a unit, so the
982        // token handed to parse_pixel_value is "1e" — CSS `calc(1e3px)` is not supported.
983        assert!(parse_calc_expression("1e3px").is_err());
984        assert!(parse_calc_expression("1e40").is_err());
985        assert!(parse_calc_expression("1E3px").is_err());
986    }
987
988    #[test]
989    fn calc_every_single_ascii_char_is_panic_free() {
990        for b in 0u8..128 {
991            let s = String::from(b as char);
992            // Only requirement: no panic, no hang. (Operators/digits are Ok, the rest Err.)
993            let _ = parse_calc_expression(&s);
994        }
995    }
996
997    #[test]
998    fn calc_fuzz_triples_never_panic_and_reprint_keeps_the_shape() {
999        // Deterministic mini-fuzz over the tokeniser's decision points, including two
1000        // multi-byte chars to smoke out any non-char-boundary slicing.
1001        const ALPHABET: [&str; 16] = [
1002            "(", ")", "+", "-", "*", "/", ".", "0", "9", "p", "x", "%", " ", "e", "é", "\u{1F600}",
1003        ];
1004
1005        for a in ALPHABET {
1006            for b in ALPHABET {
1007                for c in ALPHABET {
1008                    let input = format!("{a}{b}{c}");
1009                    let Ok(ast) = parse_calc_expression(&input) else {
1010                        continue;
1011                    };
1012                    assert!(!ast.is_empty(), "Ok(..) must never be an empty AST");
1013
1014                    // encode == decode: printing an AST and re-parsing it must give back
1015                    // the same sequence of item kinds.
1016                    let printed = calc_ast_to_css_string(&ast);
1017                    assert!(printed.starts_with("calc(") && printed.ends_with(')'));
1018                    let inner = &printed[5..printed.len() - 1];
1019                    let reparsed = parse_calc_expression(inner).unwrap_or_else(|()| {
1020                        panic!("re-printed AST {printed:?} (from {input:?}) failed to re-parse")
1021                    });
1022                    assert_eq!(
1023                        shape(&ast),
1024                        shape(&reparsed),
1025                        "round-trip changed the AST shape: {input:?} -> {printed:?}"
1026                    );
1027                }
1028            }
1029        }
1030    }
1031
1032    // ---------------------------------------------------------------------
1033    // find_value_end
1034    // ---------------------------------------------------------------------
1035
1036    #[test]
1037    fn find_value_end_basic_offsets() {
1038        assert_eq!(find_value_end(""), 0);
1039        assert_eq!(find_value_end("10px"), 4);
1040        assert_eq!(find_value_end("100%"), 4);
1041        assert_eq!(find_value_end("-1.5em"), 6);
1042        assert_eq!(find_value_end("+2px"), 4);
1043        assert_eq!(find_value_end("3"), 1);
1044        // Stops at the first char that is neither sign/digit/dot nor unit.
1045        assert_eq!(find_value_end("10px)"), 4);
1046        assert_eq!(find_value_end("10px + 2px"), 4);
1047        assert_eq!(find_value_end("(1px)"), 0);
1048        assert_eq!(find_value_end(")"), 0);
1049        // A lone sign consumes exactly the sign, so the caller gets the un-parsable "-".
1050        assert_eq!(find_value_end("-"), 1);
1051        assert_eq!(find_value_end("- 10px"), 1);
1052    }
1053
1054    #[test]
1055    fn find_value_end_is_lax_and_hands_junk_to_the_pixel_parser() {
1056        // ADVERSARIAL: find_value_end is a *scanner*, not a validator — it happily returns
1057        // a non-empty span for these. The rejection only happens later, in parse_pixel_value.
1058        assert_eq!(find_value_end("..."), 3);
1059        assert_eq!(find_value_end("1.2.3px"), 7);
1060        assert_eq!(find_value_end("1px%em"), 6);
1061        assert_eq!(find_value_end("--"), 1);
1062        // ...which is why all of these end up as Err from the calc parser:
1063        for junk in ["...", "1.2.3px", "1px%em"] {
1064            assert!(parse_calc_expression(junk).is_err(), "{junk:?}");
1065        }
1066    }
1067
1068    #[test]
1069    fn find_value_end_stops_at_an_exponent_marker() {
1070        // 'e' is treated as the start of a unit, so the digit scan never sees "e40".
1071        assert_eq!(find_value_end("1e40"), 2);
1072        assert_eq!(find_value_end("1e40px"), 2);
1073    }
1074
1075    #[test]
1076    fn find_value_end_result_is_always_an_in_bounds_char_boundary() {
1077        // THE safety invariant: parse_calc_expression slices `&rest[..end]` with this
1078        // offset, so a non-boundary result would be an instant panic on any unicode input.
1079        for s in [
1080            "",
1081            " ",
1082            "10px",
1083            "\u{1F600}",
1084            "10px\u{1F600}",
1085            "1\u{0301}px",
1086            "é",
1087            "9é",
1088            "%é",
1089            "9%é",
1090            "10px",
1091            "\u{00A0}10px",
1092            "10\u{2212}5",
1093            "px\u{4e2d}\u{6587}",
1094        ] {
1095            let end = find_value_end(s);
1096            assert!(end <= s.len(), "{s:?}: end {end} out of bounds");
1097            assert!(
1098                s.is_char_boundary(end),
1099                "{s:?}: end {end} is not a char boundary"
1100            );
1101            // Slicing with the returned offset must be safe.
1102            let _ = &s[..end];
1103        }
1104    }
1105
1106    #[test]
1107    fn find_value_end_long_input_terminates() {
1108        let long = "9".repeat(200_000);
1109        assert_eq!(find_value_end(&long), 200_000);
1110        let long_unit = format!("{}{}", "9".repeat(100_000), "x".repeat(100_000));
1111        assert_eq!(find_value_end(&long_unit), 200_000);
1112    }
1113
1114    // ---------------------------------------------------------------------
1115    // calc_ast_to_css_string
1116    // ---------------------------------------------------------------------
1117
1118    #[test]
1119    fn calc_ast_to_css_string_of_empty_vec_is_empty_calc() {
1120        assert_eq!(calc_ast_to_css_string(&CalcAstItemVec::new()), "calc()");
1121        // ...and that output is not itself re-parsable, i.e. an empty AST is not a
1122        // legal calc() — parse_layout_width rejects it.
1123        assert!(parse_layout_width("calc()").is_err());
1124    }
1125
1126    #[test]
1127    fn calc_ast_to_css_string_prints_every_variant() {
1128        let items = CalcAstItemVec::from_vec(vec![
1129            CalcAstItem::Value(PixelValue::px(1.0)),
1130            CalcAstItem::Add,
1131            CalcAstItem::Sub,
1132            CalcAstItem::Mul,
1133            CalcAstItem::Div,
1134            CalcAstItem::BraceOpen,
1135            CalcAstItem::BraceClose,
1136        ]);
1137        assert_eq!(calc_ast_to_css_string(&items), "calc(1px + - * / ( ))");
1138    }
1139
1140    #[test]
1141    fn calc_ast_to_css_string_never_prints_nan_or_inf() {
1142        // Even if a caller builds a PixelValue from NaN/inf/f32::MAX (e.g. over FFI),
1143        // the printed CSS must stay a parsable finite number.
1144        for v in [
1145            f32::NAN,
1146            f32::INFINITY,
1147            f32::NEG_INFINITY,
1148            f32::MAX,
1149            f32::MIN,
1150            f32::MIN_POSITIVE,
1151        ] {
1152            let items = CalcAstItemVec::from_vec(vec![CalcAstItem::Value(PixelValue::px(v))]);
1153            let printed = calc_ast_to_css_string(&items);
1154            assert!(!printed.contains("NaN"), "{v} printed as {printed:?}");
1155            assert!(!printed.contains("inf"), "{v} printed as {printed:?}");
1156            assert!(printed.starts_with("calc(") && printed.ends_with("px)"));
1157            // and it must survive a re-parse
1158            let inner = &printed[5..printed.len() - 1];
1159            assert!(
1160                parse_calc_expression(inner).is_ok(),
1161                "{printed:?} did not re-parse"
1162            );
1163        }
1164        // NaN specifically collapses to 0.
1165        let items = CalcAstItemVec::from_vec(vec![CalcAstItem::Value(PixelValue::px(f32::NAN))]);
1166        assert_eq!(calc_ast_to_css_string(&items), "calc(0px)");
1167    }
1168
1169    #[test]
1170    fn calc_ast_print_parse_roundtrip_is_exact_for_representable_values() {
1171        for src in [
1172            "100% - 20px",
1173            "(100% - 20px) / 3",
1174            "-10px + 5px",
1175            "10px - -5px",
1176            "1.5em * 2",
1177            "100vw - 2rem",
1178            "50% + 1.25in",
1179            "((1px + 2px) * (3px - 4px))",
1180        ] {
1181            let ast = parse_calc_expression(src).unwrap();
1182            let printed = calc_ast_to_css_string(&ast);
1183            let inner = &printed[5..printed.len() - 1];
1184            let reparsed = parse_calc_expression(inner).unwrap();
1185            assert_eq!(
1186                ast.as_slice(),
1187                reparsed.as_slice(),
1188                "round-trip mismatch for {src:?} (printed as {printed:?})"
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn calc_ast_to_css_string_is_lossy_for_adjacent_values() {
1195        // ADVERSARIAL: the printer joins items with a single space and adds no
1196        // disambiguating parens, so an AST holding two adjacent Values — reachable via the
1197        // FFI/api constructors, though not via parse_calc_expression — prints to CSS that
1198        // re-parses as a *subtraction*. The encoding is not injective.
1199        let items = CalcAstItemVec::from_vec(vec![
1200            CalcAstItem::Value(PixelValue::px(1.0)),
1201            CalcAstItem::Value(PixelValue::px(-1.0)),
1202        ]);
1203        let printed = calc_ast_to_css_string(&items);
1204        assert_eq!(printed, "calc(1px -1px)");
1205
1206        let reparsed = parse_calc_expression(&printed[5..printed.len() - 1]).unwrap();
1207        assert_eq!(shape(&items), vec![0, 0]);
1208        assert_eq!(shape(&reparsed), vec![0, 2, 0]); // Value, Sub, Value — not the input!
1209        assert_ne!(items.as_slice(), reparsed.as_slice());
1210    }
1211
1212    #[test]
1213    fn calc_ast_to_css_string_handles_a_huge_ast() {
1214        let items = CalcAstItemVec::from_vec(vec![CalcAstItem::BraceOpen; 100_000]);
1215        let printed = calc_ast_to_css_string(&items);
1216        // 100_000 "(" joined by 100_000 - 1 spaces, plus "calc(" and ")"
1217        assert_eq!(printed.len(), 100_000 * 2 - 1 + 6);
1218    }
1219
1220    // ---------------------------------------------------------------------
1221    // parse_layout_box_sizing + LayoutBoxSizingParseError round-trips
1222    // ---------------------------------------------------------------------
1223
1224    #[test]
1225    fn box_sizing_valid_inputs_and_trimming() {
1226        assert_eq!(
1227            parse_layout_box_sizing("content-box").unwrap(),
1228            LayoutBoxSizing::ContentBox
1229        );
1230        assert_eq!(
1231            parse_layout_box_sizing("border-box").unwrap(),
1232            LayoutBoxSizing::BorderBox
1233        );
1234        assert_eq!(
1235            parse_layout_box_sizing("\t\n  border-box \r\n ").unwrap(),
1236            LayoutBoxSizing::BorderBox
1237        );
1238        // encode == decode
1239        for v in [LayoutBoxSizing::ContentBox, LayoutBoxSizing::BorderBox] {
1240            assert_eq!(parse_layout_box_sizing(&v.print_as_css_value()).unwrap(), v);
1241        }
1242    }
1243
1244    #[test]
1245    fn box_sizing_empty_whitespace_and_garbage_are_err() {
1246        for input in [
1247            "",
1248            "   ",
1249            "\t\n",
1250            "padding-box",
1251            "borderbox",
1252            "border box",
1253            "content-box;",
1254            "content-box border-box",
1255            "content-box!",
1256            "\0",
1257            "-",
1258            "\u{1F600}",
1259            "content-box\u{0301}",
1260            "cöntent-box",
1261            "content\u{2010}box", // unicode hyphen, not ASCII '-'
1262        ] {
1263            assert!(
1264                parse_layout_box_sizing(input).is_err(),
1265                "expected Err for {input:?}"
1266            );
1267        }
1268    }
1269
1270    #[test]
1271    fn box_sizing_rejects_numeric_boundary_strings() {
1272        for input in [
1273            "0",
1274            "-0",
1275            "NaN",
1276            "inf",
1277            "9223372036854775807",
1278            "-9223372036854775808",
1279            "3.4028235e38",
1280            "1e-45",
1281        ] {
1282            assert!(
1283                parse_layout_box_sizing(input).is_err(),
1284                "expected Err for {input:?}"
1285            );
1286        }
1287    }
1288
1289    #[test]
1290    fn box_sizing_keyword_matching_is_case_sensitive() {
1291        // CSS keywords are ASCII case-insensitive per spec; this parser is not.
1292        // Documenting the current behaviour so a future fix has to update this test.
1293        assert!(parse_layout_box_sizing("Content-Box").is_err());
1294        assert!(parse_layout_box_sizing("BORDER-BOX").is_err());
1295    }
1296
1297    #[test]
1298    fn box_sizing_extremely_long_input_is_err_and_terminates() {
1299        let long = "a".repeat(1_000_000);
1300        assert!(parse_layout_box_sizing(&long).is_err());
1301
1302        // A long *valid* keyword surrounded by whitespace still trims down to Ok.
1303        let padded = format!("{}border-box{}", " ".repeat(100_000), " ".repeat(100_000));
1304        assert_eq!(
1305            parse_layout_box_sizing(&padded).unwrap(),
1306            LayoutBoxSizing::BorderBox
1307        );
1308    }
1309
1310    #[test]
1311    fn box_sizing_error_payload_is_the_trimmed_input() {
1312        let err = parse_layout_box_sizing("  bogus  ").unwrap_err();
1313        match &err {
1314            LayoutBoxSizingParseError::InvalidValue(s) => assert_eq!(*s, "bogus"),
1315        }
1316        assert_eq!(format!("{err}"), "Invalid box-sizing value: \"bogus\"");
1317    }
1318
1319    #[test]
1320    fn box_sizing_error_to_contained_to_shared_roundtrip() {
1321        let err = parse_layout_box_sizing("padding-box").unwrap_err();
1322        let owned = err.to_contained();
1323        assert_eq!(
1324            owned,
1325            LayoutBoxSizingParseErrorOwned::InvalidValue("padding-box".to_string().into())
1326        );
1327        // to_shared() must reproduce exactly what to_contained() consumed.
1328        assert_eq!(owned.to_shared(), err);
1329        // ...and be idempotent under repeated round-trips.
1330        assert_eq!(owned.to_shared().to_contained(), owned);
1331    }
1332
1333    #[test]
1334    fn box_sizing_error_roundtrip_with_empty_and_unicode_payloads() {
1335        for input in ["", "   ", "\u{1F600}\u{0301}é", "a\0b"] {
1336            let err = parse_layout_box_sizing(input).unwrap_err();
1337            let owned = err.to_contained();
1338            assert_eq!(owned.to_shared(), err, "round-trip failed for {input:?}");
1339
1340            match &owned {
1341                LayoutBoxSizingParseErrorOwned::InvalidValue(s) => {
1342                    assert_eq!(s.as_str(), input.trim());
1343                }
1344            }
1345        }
1346    }
1347
1348    #[test]
1349    fn box_sizing_error_roundtrip_with_a_huge_payload() {
1350        let long = "x".repeat(200_000);
1351        let err = parse_layout_box_sizing(&long).unwrap_err();
1352        let owned = err.to_contained();
1353        match &owned {
1354            LayoutBoxSizingParseErrorOwned::InvalidValue(s) => {
1355                assert_eq!(s.as_str().len(), 200_000);
1356            }
1357        }
1358        assert_eq!(owned.to_shared(), err);
1359    }
1360
1361    // ---------------------------------------------------------------------
1362    // The sizing parsers — the only public path into the calc()/fit-content() slicing
1363    // ---------------------------------------------------------------------
1364
1365    #[test]
1366    fn sizing_parser_paren_slicing_is_panic_free() {
1367        // Both branches slice with hard-coded byte offsets (`s[5..len-1]`, `s[12..len-1]`).
1368        // These inputs are the ones that would trip an off-by-one or a char-boundary bug.
1369        for input in [
1370            "calc()",
1371            "fit-content()",
1372            "fit-content(\u{1F600})",
1373            "calc(\u{1F600})",
1374            "calc(é)",
1375            "fit-content(é)",
1376            "calc( )",
1377            "fit-content( )",
1378            "calc(1px)garbage)",
1379            "fit-content(1px)garbage)",
1380            "fit-content(1px",
1381            "calc(1px",
1382        ] {
1383            // Only requirement: no panic. (All of these must also be Err.)
1384            assert!(
1385                parse_layout_width(input).is_err(),
1386                "expected Err for {input:?}"
1387            );
1388            assert!(
1389                parse_layout_height(input).is_err(),
1390                "expected Err for {input:?}"
1391            );
1392        }
1393    }
1394
1395    #[test]
1396    fn sizing_parser_keywords_and_calc_roundtrip() {
1397        let cases = [
1398            (LayoutWidth::Auto, "auto"),
1399            (LayoutWidth::MinContent, "min-content"),
1400            (LayoutWidth::MaxContent, "max-content"),
1401            (LayoutWidth::Px(PixelValue::px(150.0)), "150px"),
1402            (LayoutWidth::FitContent(PixelValue::percent(50.0)), "fit-content(50%)"),
1403        ];
1404        for (value, css) in cases {
1405            assert_eq!(parse_layout_width(css).unwrap(), value, "parse of {css:?}");
1406            assert_eq!(value.print_as_css_value(), css, "print of {css:?}");
1407        }
1408
1409        // calc() survives the full encode/decode cycle.
1410        let parsed = parse_layout_width("calc(100% - 20px)").unwrap();
1411        assert_eq!(parsed.print_as_css_value(), "calc(100% - 20px)");
1412        assert_eq!(parse_layout_width(&parsed.print_as_css_value()).unwrap(), parsed);
1413        assert_eq!(
1414            calc_items(&parsed),
1415            vec![
1416                CalcAstItem::Value(PixelValue::percent(100.0)),
1417                CalcAstItem::Sub,
1418                CalcAstItem::Value(PixelValue::px(20.0)),
1419            ]
1420        );
1421    }
1422
1423    #[test]
1424    fn sizing_parser_keywords_are_case_sensitive() {
1425        // Same spec deviation as box-sizing: CSS keywords should be case-insensitive.
1426        for input in ["AUTO", "Auto", "MIN-CONTENT", "CALC(1px)", "FIT-CONTENT(1px)"] {
1427            assert!(
1428                parse_layout_width(input).is_err(),
1429                "expected Err for {input:?}"
1430            );
1431        }
1432    }
1433
1434    #[test]
1435    fn fit_content_clamps_negative_values_to_zero() {
1436        // Documented invariant of the parser: fit-content() can never be negative.
1437        assert_eq!(
1438            parse_layout_width("fit-content(-10px)").unwrap(),
1439            LayoutWidth::FitContent(PixelValue::zero())
1440        );
1441        assert_eq!(
1442            parse_layout_height("fit-content(-99999%)").unwrap(),
1443            LayoutHeight::FitContent(PixelValue::zero())
1444        );
1445        // NaN quantises to 0, which is >= 0.0, so it takes the non-negative branch.
1446        assert_eq!(
1447            parse_layout_width("fit-content(NaN)").unwrap(),
1448            LayoutWidth::FitContent(PixelValue::zero())
1449        );
1450        // A huge value is kept, but saturated to a finite number.
1451        match parse_layout_width("fit-content(99999999999999999999999999999999999999999px)")
1452            .unwrap()
1453        {
1454            LayoutWidth::FitContent(v) => assert!(v.number.get().is_finite()),
1455            other => panic!("expected FitContent, got {other:?}"),
1456        }
1457    }
1458
1459    #[test]
1460    fn sizing_parser_deeply_nested_calc_does_not_stack_overflow() {
1461        const DEPTH: usize = 5_000;
1462        let input = format!(
1463            "calc({}1px{})",
1464            "(".repeat(DEPTH),
1465            ")".repeat(DEPTH)
1466        );
1467        let parsed = parse_layout_width(&input).unwrap();
1468        assert_eq!(calc_items(&parsed).len(), DEPTH * 2 + 1);
1469        // Printing must survive it too (this is what the CSS serialiser calls).
1470        let printed = parsed.print_as_css_value();
1471        assert_eq!(printed.matches('(').count(), DEPTH + 1);
1472        assert_eq!(printed.matches(')').count(), DEPTH + 1);
1473    }
1474
1475    #[test]
1476    fn sizing_parser_error_carries_the_untrimmed_input_for_bad_calc() {
1477        // Quirk worth pinning: InvalidKeyword gets the *raw* `input`, not the trimmed
1478        // string (unlike box-sizing, which reports the trimmed value).
1479        let err = parse_layout_width("  calc(??)  ").unwrap_err();
1480        match &err {
1481            LayoutWidthParseError::InvalidKeyword(k) => assert_eq!(*k, "  calc(??)  "),
1482            other => panic!("expected InvalidKeyword, got {other:?}"),
1483        }
1484        // ...and it round-trips through the owned representation unchanged.
1485        let owned = err.to_contained();
1486        assert_eq!(owned.to_shared(), err);
1487    }
1488
1489    #[test]
1490    fn pixel_dimension_parser_errors_roundtrip() {
1491        for input in ["", "   ", "px", "garbage", "\u{1F600}", "1.2.3px"] {
1492            let err = parse_layout_min_width(input)
1493                .err()
1494                .unwrap_or_else(|| panic!("expected Err for {input:?}"));
1495            let owned = err.to_contained();
1496            assert_eq!(owned.to_shared(), err, "for {input:?}");
1497
1498            assert!(parse_layout_max_height(input).is_err(), "for {input:?}");
1499        }
1500        assert_eq!(
1501            parse_layout_min_width("0").unwrap(),
1502            LayoutMinWidth {
1503                inner: PixelValue::px(0.0)
1504            }
1505        );
1506    }
1507
1508    // ---------------------------------------------------------------------
1509    // Defaults / numeric invariants
1510    // ---------------------------------------------------------------------
1511
1512    #[test]
1513    fn max_dimension_defaults_are_finite_but_not_actually_f32_max() {
1514        // The doc comment says the default is `f32::MAX` pixels. It isn't: FloatValue
1515        // stores number * 1000 in an isize, and `f32::MAX * 1000.0` = inf saturates to
1516        // isize::MAX — so `get()` comes back as ~9.2e15, not 3.4e38. The sentinel is still
1517        // "effectively unconstrained" and, importantly, finite (so the solver's
1518        // padding/margin additions cannot reach inf) — but it is NOT f32::MAX.
1519        for got in [
1520            LayoutMaxWidth::default().inner.number.get(),
1521            LayoutMaxHeight::default().inner.number.get(),
1522        ] {
1523            assert!(got.is_finite(), "default max dimension is not finite: {got}");
1524            assert!(got > 0.0);
1525            assert_ne!(got, f32::MAX);
1526        }
1527        // The min-* defaults are exactly zero.
1528        assert_eq!(LayoutMinWidth::default().inner.number.get(), 0.0);
1529        assert_eq!(LayoutMinHeight::default().inner.number.get(), 0.0);
1530        assert_eq!(LayoutWidth::default(), LayoutWidth::Auto);
1531        assert_eq!(LayoutHeight::default(), LayoutHeight::Auto);
1532        assert_eq!(LayoutBoxSizing::default(), LayoutBoxSizing::ContentBox);
1533    }
1534
1535    #[test]
1536    fn sizing_interpolate_endpoints_and_nan_t() {
1537        let a = LayoutWidth::px(10.0);
1538        let b = LayoutWidth::px(20.0);
1539        assert_eq!(a.interpolate(&b, 0.0), a);
1540        assert_eq!(a.interpolate(&b, 1.0), b);
1541        assert_eq!(a.interpolate(&b, 0.5), LayoutWidth::px(15.0));
1542
1543        // NaN `t` must not panic and must not leak a NaN into the value.
1544        match a.interpolate(&b, f32::NAN) {
1545            LayoutWidth::Px(v) => {
1546                assert!(!v.number.get().is_nan());
1547                assert_eq!(v.number.get(), 0.0);
1548            }
1549            other => panic!("expected Px, got {other:?}"),
1550        }
1551
1552        // Discrete keywords snap rather than blend, and NaN falls through to `other`.
1553        let auto = LayoutWidth::Auto;
1554        let min = LayoutWidth::MinContent;
1555        assert_eq!(auto.interpolate(&min, 0.0), LayoutWidth::Auto);
1556        assert_eq!(auto.interpolate(&min, 1.0), LayoutWidth::MinContent);
1557        assert_eq!(auto.interpolate(&min, f32::NAN), LayoutWidth::MinContent);
1558
1559        // Interpolating a calc() clones the AST (no double-free, no panic).
1560        let calc = parse_layout_width("calc(100% - 20px)").unwrap();
1561        assert_eq!(calc.interpolate(&auto, 0.0), calc);
1562        assert_eq!(auto.interpolate(&calc, 1.0), calc);
1563    }
1564
1565    #[test]
1566    fn sizing_parser_px_quantisation_limits() {
1567        // Sub-0.001 collapses to zero...
1568        match parse_layout_width("0.0005px").unwrap() {
1569            LayoutWidth::Px(v) => assert_eq!(v.number.get(), 0.0),
1570            other => panic!("expected Px, got {other:?}"),
1571        }
1572        // ...and an out-of-f32-range literal saturates to a finite value rather than inf.
1573        match parse_layout_width(&format!("{}px", "9".repeat(60))).unwrap() {
1574            LayoutWidth::Px(v) => assert!(v.number.get().is_finite()),
1575            other => panic!("expected Px, got {other:?}"),
1576        }
1577        // A bare NaN literal is accepted as a length and quantises to 0.
1578        match parse_layout_width("NaN").unwrap() {
1579            LayoutWidth::Px(v) => assert_eq!(v.number.get(), 0.0),
1580            other => panic!("expected Px, got {other:?}"),
1581        }
1582    }
1583}