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