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

            
12
use alloc::{
13
    string::{String, ToString},
14
    vec::Vec,
15
};
16

            
17
use 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)]
45
pub 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.
63
impl_vec!(
64
    CalcAstItem,
65
    CalcAstItemVec,
66
    CalcAstItemVecDestructor,
67
    CalcAstItemVecDestructorType,
68
    CalcAstItemVecSlice,
69
    OptionCalcAstItem
70
);
71
impl_vec_clone!(CalcAstItem, CalcAstItemVec, CalcAstItemVecDestructor);
72
impl_vec_debug!(CalcAstItem, CalcAstItemVec);
73
impl_vec_partialeq!(CalcAstItem, CalcAstItemVec);
74
impl_vec_eq!(CalcAstItem, CalcAstItemVec);
75
impl_vec_partialord!(CalcAstItem, CalcAstItemVec);
76
impl_vec_ord!(CalcAstItem, CalcAstItemVec);
77
impl_vec_hash!(CalcAstItem, CalcAstItemVec);
78
impl_vec_mut!(CalcAstItem, CalcAstItemVec);
79

            
80
impl_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")]
101
5074
fn parse_calc_expression(input: &str) -> Result<CalcAstItemVec, ()> {
102
    use crate::props::basic::pixel::parse_pixel_value;
103

            
104
5074
    let mut items: Vec<CalcAstItem> = Vec::new();
105
5074
    let input = input.trim();
106
5074
    let bytes = input.as_bytes();
107
5074
    let mut i = 0;
108

            
109
271302
    while i < bytes.len() {
110
        // Skip whitespace
111
269779
        if bytes[i].is_ascii_whitespace() {
112
101193
            i += 1;
113
101193
            continue;
114
168586
        }
115

            
116
168586
        match bytes[i] {
117
50683
            b'+' => { items.push(CalcAstItem::Add); i += 1; }
118
680
            b'*' => { items.push(CalcAstItem::Mul); i += 1; }
119
678
            b'/' => { items.push(CalcAstItem::Div); i += 1; }
120
35691
            b'(' => { items.push(CalcAstItem::BraceOpen); i += 1; }
121
25708
            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
606
                let is_negative_number = items.is_empty()
128
192
                    || matches!(
129
304
                        items.last(),
130
                        Some(CalcAstItem::Add | CalcAstItem::Sub | CalcAstItem::Mul | CalcAstItem::Div
131
| CalcAstItem::BraceOpen)
132
                    );
133

            
134
606
                if is_negative_number {
135
                    // Parse as negative number value
136
414
                    let rest = &input[i..];
137
414
                    let end = find_value_end(rest);
138
414
                    if end == 0 { return Err(()); }
139
414
                    let val_str = &rest[..end];
140
414
                    let pv = parse_pixel_value(val_str).map_err(|_| ())?;
141
70
                    items.push(CalcAstItem::Value(pv));
142
70
                    i += end;
143
192
                } else {
144
192
                    items.push(CalcAstItem::Sub);
145
192
                    i += 1;
146
192
                }
147
            }
148
            _ => {
149
                // Must be a numeric value (e.g. 100%, 20px, 3, 1.5em)
150
54540
                let rest = &input[i..];
151
54540
                let end = find_value_end(rest);
152
54540
                if end == 0 { return Err(()); }
153
53544
                let val_str = &rest[..end];
154
53544
                let pv = parse_pixel_value(val_str).map_err(|_| ())?;
155
51333
                items.push(CalcAstItem::Value(pv));
156
51333
                i += end;
157
            }
158
        }
159
    }
160

            
161
1523
    if items.is_empty() {
162
15
        return Err(());
163
1508
    }
164

            
165
1508
    Ok(CalcAstItemVec::from(items))
166
5074
}
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")]
171
54988
fn find_value_end(s: &str) -> usize {
172
54988
    let bytes = s.as_bytes();
173
54988
    let mut i = 0;
174

            
175
    // Optional leading sign
176
54988
    if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
177
419
        i += 1;
178
54569
    }
179

            
180
    // Digits and decimal point
181
507658
    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
182
452670
        i += 1;
183
452670
    }
184

            
185
    // Unit suffix (alphabetic characters like px, %, em, rem, vw, vh, etc.)
186
358831
    while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'%') {
187
303843
        i += 1;
188
303843
    }
189

            
190
54988
    i
191
54988
}
192

            
193
/// Format a `CalcAstItemVec` as a CSS `calc(...)` string.
194
724
fn calc_ast_to_css_string(items: &CalcAstItemVec) -> String {
195
131711
    let inner: Vec<String> = items.iter().map(|i| match i {
196
540
        CalcAstItem::Value(v) => v.to_string(),
197
218
        CalcAstItem::Add => "+".to_string(),
198
67
        CalcAstItem::Sub => "-".to_string(),
199
217
        CalcAstItem::Mul => "*".to_string(),
200
216
        CalcAstItem::Div => "/".to_string(),
201
115219
        CalcAstItem::BraceOpen => "(".to_string(),
202
15234
        CalcAstItem::BraceClose => ")".to_string(),
203
131711
    }).collect();
204
724
    alloc::format!("calc({})", inner.join(" "))
205
724
}
206

            
207
// -- Type Definitions --
208

            
209
macro_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
4
            fn default() -> Self {
219
4
                $default_fn()
220
4
            }
221
        }
222

            
223
        impl PixelValueTaker for $struct_name {
224
17831
            fn from_pixel_value(inner: PixelValue) -> Self {
225
17831
                Self { inner }
226
17831
            }
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

            
239
macro_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
408
            fn print_as_css_value(&self) -> String {
264
408
                match self {
265
1
                    $name::Auto => "auto".to_string(),
266
401
                    $name::Px(v) => v.to_string(),
267
1
                    $name::MinContent => "min-content".to_string(),
268
1
                    $name::MaxContent => "max-content".to_string(),
269
1
                    $name::FitContent(v) => alloc::format!("fit-content({})", v),
270
3
                    $name::Calc(items) => calc_ast_to_css_string(items),
271
                }
272
408
            }
273
        }
274

            
275
        impl $name {
276
5959
            #[must_use] pub fn px(value: f32) -> Self {
277
5959
                $name::Px(PixelValue::px(value))
278
5959
            }
279

            
280
343679
            #[must_use] pub const fn const_px(value: isize) -> Self {
281
343679
                $name::Px(PixelValue::const_px(value))
282
343679
            }
283

            
284
1449
            #[must_use] pub fn interpolate(&self, other: &Self, t: f32) -> Self {
285
1449
                match (self, other) {
286
1444
                    ($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
5
                    (a, _) if t < 0.5 => a.clone(),
292
3
                    (_, b) => b.clone(),
293
                }
294
1449
            }
295
        }
296
    };
297
}
298

            
299
define_sizing_enum!(LayoutWidth);
300
define_sizing_enum!(LayoutHeight);
301

            
302
/// CSS `min-width` property. Defaults to `0px`.
303
define_dimension_property!(LayoutMinWidth, || Self {
304
1
    inner: PixelValue::zero()
305
1
});
306
/// CSS `min-height` property. Defaults to `0px`.
307
define_dimension_property!(LayoutMinHeight, || Self {
308
1
    inner: PixelValue::zero()
309
1
});
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.
314
define_dimension_property!(LayoutMaxWidth, || Self {
315
1
    inner: PixelValue::px(core::f32::MAX)
316
1
});
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.
321
define_dimension_property!(LayoutMaxHeight, || Self {
322
1
    inner: PixelValue::px(core::f32::MAX)
323
1
});
324

            
325
/// Represents a `box-sizing` attribute
326
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
327
#[repr(C)]
328
#[derive(Default)]
329
pub enum LayoutBoxSizing {
330
    #[default]
331
    ContentBox,
332
    BorderBox,
333
}
334

            
335

            
336
impl PrintAsCssValue for LayoutBoxSizing {
337
2
    fn print_as_css_value(&self) -> String {
338
2
        String::from(match self {
339
1
            Self::ContentBox => "content-box",
340
1
            Self::BorderBox => "border-box",
341
        })
342
2
    }
343
}
344

            
345
// -- Parser --
346

            
347
#[cfg(feature = "parser")]
348
pub 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
6
                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
379
6
                    match self {
380
6
                        $error_name::PixelValue(e) => {
381
6
                            $error_owned_name::PixelValue(e.to_contained())
382
                        }
383
                    }
384
6
                }
385
            }
386

            
387
            impl $error_owned_name {
388
6
                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
389
6
                    match self {
390
6
                        $error_owned_name::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
391
                    }
392
6
                }
393
            }
394

            
395
            /// # Errors
396
            ///
397
            /// Returns an error if `input` is not a valid CSS value for this property.
398
3514
            pub fn $fn_name(input: &str) -> Result<$struct_name, $error_name<'_>> {
399
3514
                parse_pixel_value(input)
400
3514
                    .map(|v| $struct_name { inner: v })
401
3514
                    .map_err($error_name::PixelValue)
402
3514
            }
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
5
                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
431
5
                    match self {
432
4
                        $error_name::PixelValue(e) => {
433
4
                            $error_owned_name::PixelValue(e.to_contained())
434
                        }
435
1
                        $error_name::InvalidKeyword(k) => {
436
1
                            $error_owned_name::InvalidKeyword(k.to_string().into())
437
                        }
438
                    }
439
5
                }
440
            }
441

            
442
            impl $error_owned_name {
443
5
                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
444
5
                    match self {
445
4
                        $error_owned_name::PixelValue(e) => {
446
4
                            $error_name::PixelValue(e.to_shared())
447
                        }
448
1
                        $error_owned_name::InvalidKeyword(k) => {
449
1
                            $error_name::InvalidKeyword(k)
450
                        }
451
                    }
452
5
                }
453
            }
454

            
455
            /// # Errors
456
            ///
457
            /// Returns an error if `input` is not a valid CSS value for this property.
458
51101
            pub fn $fn_name(
459
51101
                input: &str,
460
51101
            ) -> Result<$enum_name, $error_name<'_>> {
461
51101
                let trimmed = input.trim();
462
51080
                match trimmed {
463
51101
                    "auto" => Ok($enum_name::Auto),
464
51099
                    "min-content" => Ok($enum_name::MinContent),
465
51097
                    "max-content" => Ok($enum_name::MaxContent),
466
51095
                    s if s.starts_with("fit-content(") && s.ends_with(')') => {
467
15
                        let inner = &s[12..s.len() - 1].trim();
468
15
                        parse_pixel_value(inner)
469
15
                            .map(|pv| {
470
5
                                if pv.number.get() < 0.0 {
471
2
                                    $enum_name::FitContent(PixelValue::zero())
472
                                } else {
473
3
                                    $enum_name::FitContent(pv)
474
                                }
475
5
                            })
476
15
                            .map_err($error_name::PixelValue)
477
                    }
478
51080
                    s if s.starts_with("calc(") && s.ends_with(')') => {
479
48
                        let inner = &s[5..s.len() - 1];
480
48
                        parse_calc_expression(inner)
481
48
                            .map($enum_name::Calc)
482
48
                            .map_err(|_| $error_name::InvalidKeyword(input))
483
                    }
484
51032
                    _ => parse_pixel_value(trimmed)
485
51032
                        .map($enum_name::Px)
486
51032
                        .map_err($error_name::PixelValue),
487
                }
488
51101
            }
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
7
        #[must_use] pub fn to_contained(&self) -> LayoutBoxSizingParseErrorOwned {
539
7
            match self {
540
7
                LayoutBoxSizingParseError::InvalidValue(s) => {
541
7
                    LayoutBoxSizingParseErrorOwned::InvalidValue((*s).to_string().into())
542
                }
543
            }
544
7
        }
545
    }
546

            
547
    impl LayoutBoxSizingParseErrorOwned {
548
7
        #[must_use] pub fn to_shared(&self) -> LayoutBoxSizingParseError<'_> {
549
7
            match self {
550
7
                Self::InvalidValue(s) => {
551
7
                    LayoutBoxSizingParseError::InvalidValue(s)
552
                }
553
            }
554
7
        }
555
    }
556

            
557
    /// # Errors
558
    ///
559
    /// Returns an error if `input` is not a valid CSS `box-sizing` value.
560
561
    pub fn parse_layout_box_sizing(
561
561
        input: &str,
562
561
    ) -> Result<LayoutBoxSizing, LayoutBoxSizingParseError<'_>> {
563
561
        match input.trim() {
564
561
            "content-box" => Ok(LayoutBoxSizing::ContentBox),
565
558
            "border-box" => Ok(LayoutBoxSizing::BorderBox),
566
36
            other => Err(LayoutBoxSizingParseError::InvalidValue(other)),
567
        }
568
561
    }
569
}
570

            
571
#[cfg(feature = "parser")]
572
pub use self::parser::*;
573

            
574
#[cfg(all(test, feature = "parser"))]
575
mod tests {
576
    use super::*;
577
    use crate::props::basic::pixel::PixelValue;
578

            
579
    #[test]
580
1
    fn test_parse_layout_width() {
581
1
        assert_eq!(
582
1
            parse_layout_width("150px").unwrap(),
583
1
            LayoutWidth::Px(PixelValue::px(150.0))
584
        );
585
1
        assert_eq!(
586
1
            parse_layout_width("2.5em").unwrap(),
587
1
            LayoutWidth::Px(PixelValue::em(2.5))
588
        );
589
1
        assert_eq!(
590
1
            parse_layout_width("75%").unwrap(),
591
1
            LayoutWidth::Px(PixelValue::percent(75.0))
592
        );
593
1
        assert_eq!(
594
1
            parse_layout_width("0").unwrap(),
595
1
            LayoutWidth::Px(PixelValue::px(0.0))
596
        );
597
1
        assert_eq!(
598
1
            parse_layout_width("  100pt  ").unwrap(),
599
1
            LayoutWidth::Px(PixelValue::pt(100.0))
600
        );
601
1
        assert_eq!(
602
1
            parse_layout_width("min-content").unwrap(),
603
            LayoutWidth::MinContent
604
        );
605
1
        assert_eq!(
606
1
            parse_layout_width("max-content").unwrap(),
607
            LayoutWidth::MaxContent
608
        );
609
1
    }
610

            
611
    #[test]
612
1
    fn test_parse_layout_height_invalid() {
613
        // "auto" is now a valid value for height (CSS spec)
614
1
        assert!(parse_layout_height("auto").is_ok());
615
        // Liberal parsing accepts whitespace between number and unit
616
1
        assert!(parse_layout_height("150 px").is_ok());
617
1
        assert!(parse_layout_height("px").is_err());
618
1
        assert!(parse_layout_height("invalid").is_err());
619
1
    }
620

            
621
    #[test]
622
1
    fn test_parse_layout_box_sizing() {
623
1
        assert_eq!(
624
1
            parse_layout_box_sizing("content-box").unwrap(),
625
            LayoutBoxSizing::ContentBox
626
        );
627
1
        assert_eq!(
628
1
            parse_layout_box_sizing("border-box").unwrap(),
629
            LayoutBoxSizing::BorderBox
630
        );
631
1
        assert_eq!(
632
1
            parse_layout_box_sizing("  border-box  ").unwrap(),
633
            LayoutBoxSizing::BorderBox
634
        );
635
1
    }
636

            
637
    #[test]
638
1
    fn test_parse_layout_box_sizing_invalid() {
639
1
        assert!(parse_layout_box_sizing("padding-box").is_err());
640
1
        assert!(parse_layout_box_sizing("borderbox").is_err());
641
1
        assert!(parse_layout_box_sizing("").is_err());
642
1
    }
643
}
644

            
645
#[cfg(all(test, feature = "parser"))]
646
mod 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
        // multi-byte chars to smoke out any non-char-boundary slicing.
        const ALPHABET: [&str; 16] = [
            "(", ")", "+", "-", "*", "/", ".", "0", "9", "p", "x", "%", " ", "e", "é", "\u{1F600}",
        ];
        for a in ALPHABET {
            for b in ALPHABET {
                for c in ALPHABET {
                    let input = format!("{a}{b}{c}");
                    let Ok(ast) = parse_calc_expression(&input) else {
                        continue;
                    };
                    assert!(!ast.is_empty(), "Ok(..) must never be an empty AST");
                    // encode == decode: printing an AST and re-parsing it must give back
                    // the same sequence of item kinds.
                    let printed = calc_ast_to_css_string(&ast);
                    assert!(printed.starts_with("calc(") && printed.ends_with(')'));
                    let inner = &printed[5..printed.len() - 1];
                    let reparsed = parse_calc_expression(inner).unwrap_or_else(|()| {
                        panic!("re-printed AST {printed:?} (from {input:?}) failed to re-parse")
                    });
                    assert_eq!(
                        shape(&ast),
                        shape(&reparsed),
                        "round-trip changed the AST shape: {input:?} -> {printed:?}"
                    );
                }
            }
        }
    }
    // ---------------------------------------------------------------------
    // find_value_end
    // ---------------------------------------------------------------------
    #[test]
    fn find_value_end_basic_offsets() {
        assert_eq!(find_value_end(""), 0);
        assert_eq!(find_value_end("10px"), 4);
        assert_eq!(find_value_end("100%"), 4);
        assert_eq!(find_value_end("-1.5em"), 6);
        assert_eq!(find_value_end("+2px"), 4);
        assert_eq!(find_value_end("3"), 1);
        // Stops at the first char that is neither sign/digit/dot nor unit.
        assert_eq!(find_value_end("10px)"), 4);
        assert_eq!(find_value_end("10px + 2px"), 4);
        assert_eq!(find_value_end("(1px)"), 0);
        assert_eq!(find_value_end(")"), 0);
        // A lone sign consumes exactly the sign, so the caller gets the un-parsable "-".
        assert_eq!(find_value_end("-"), 1);
        assert_eq!(find_value_end("- 10px"), 1);
    }
    #[test]
    fn find_value_end_is_lax_and_hands_junk_to_the_pixel_parser() {
        // ADVERSARIAL: find_value_end is a *scanner*, not a validator — it happily returns
        // a non-empty span for these. The rejection only happens later, in parse_pixel_value.
        assert_eq!(find_value_end("..."), 3);
        assert_eq!(find_value_end("1.2.3px"), 7);
        assert_eq!(find_value_end("1px%em"), 6);
        assert_eq!(find_value_end("--"), 1);
        // ...which is why all of these end up as Err from the calc parser:
        for junk in ["...", "1.2.3px", "1px%em"] {
            assert!(parse_calc_expression(junk).is_err(), "{junk:?}");
        }
    }
    #[test]
    fn find_value_end_stops_at_an_exponent_marker() {
        // 'e' is treated as the start of a unit, so the digit scan never sees "e40".
        assert_eq!(find_value_end("1e40"), 2);
        assert_eq!(find_value_end("1e40px"), 2);
    }
    #[test]
    fn find_value_end_result_is_always_an_in_bounds_char_boundary() {
        // THE safety invariant: parse_calc_expression slices `&rest[..end]` with this
        // offset, so a non-boundary result would be an instant panic on any unicode input.
        for s in [
            "",
            " ",
            "10px",
            "\u{1F600}",
            "10px\u{1F600}",
            "1\u{0301}px",
            "é",
            "9é",
            "%é",
            "9%é",
            "10px",
            "\u{00A0}10px",
            "10\u{2212}5",
            "px\u{4e2d}\u{6587}",
        ] {
            let end = find_value_end(s);
            assert!(end <= s.len(), "{s:?}: end {end} out of bounds");
            assert!(
                s.is_char_boundary(end),
                "{s:?}: end {end} is not a char boundary"
            );
            // Slicing with the returned offset must be safe.
            let _ = &s[..end];
        }
    }
    #[test]
    fn find_value_end_long_input_terminates() {
        let long = "9".repeat(200_000);
        assert_eq!(find_value_end(&long), 200_000);
        let long_unit = format!("{}{}", "9".repeat(100_000), "x".repeat(100_000));
        assert_eq!(find_value_end(&long_unit), 200_000);
    }
    // ---------------------------------------------------------------------
    // calc_ast_to_css_string
    // ---------------------------------------------------------------------
    #[test]
    fn calc_ast_to_css_string_of_empty_vec_is_empty_calc() {
        assert_eq!(calc_ast_to_css_string(&CalcAstItemVec::new()), "calc()");
        // ...and that output is not itself re-parsable, i.e. an empty AST is not a
        // legal calc() — parse_layout_width rejects it.
        assert!(parse_layout_width("calc()").is_err());
    }
    #[test]
    fn calc_ast_to_css_string_prints_every_variant() {
        let items = CalcAstItemVec::from_vec(vec![
            CalcAstItem::Value(PixelValue::px(1.0)),
            CalcAstItem::Add,
            CalcAstItem::Sub,
            CalcAstItem::Mul,
            CalcAstItem::Div,
            CalcAstItem::BraceOpen,
            CalcAstItem::BraceClose,
        ]);
        assert_eq!(calc_ast_to_css_string(&items), "calc(1px + - * / ( ))");
    }
    #[test]
    fn calc_ast_to_css_string_never_prints_nan_or_inf() {
        // Even if a caller builds a PixelValue from NaN/inf/f32::MAX (e.g. over FFI),
        // the printed CSS must stay a parsable finite number.
        for v in [
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
            f32::MAX,
            f32::MIN,
            f32::MIN_POSITIVE,
        ] {
            let items = CalcAstItemVec::from_vec(vec![CalcAstItem::Value(PixelValue::px(v))]);
            let printed = calc_ast_to_css_string(&items);
            assert!(!printed.contains("NaN"), "{v} printed as {printed:?}");
            assert!(!printed.contains("inf"), "{v} printed as {printed:?}");
            assert!(printed.starts_with("calc(") && printed.ends_with("px)"));
            // and it must survive a re-parse
            let inner = &printed[5..printed.len() - 1];
            assert!(
                parse_calc_expression(inner).is_ok(),
                "{printed:?} did not re-parse"
            );
        }
        // NaN specifically collapses to 0.
        let items = CalcAstItemVec::from_vec(vec![CalcAstItem::Value(PixelValue::px(f32::NAN))]);
        assert_eq!(calc_ast_to_css_string(&items), "calc(0px)");
    }
    #[test]
    fn calc_ast_print_parse_roundtrip_is_exact_for_representable_values() {
        for src in [
            "100% - 20px",
            "(100% - 20px) / 3",
            "-10px + 5px",
            "10px - -5px",
            "1.5em * 2",
            "100vw - 2rem",
            "50% + 1.25in",
            "((1px + 2px) * (3px - 4px))",
        ] {
            let ast = parse_calc_expression(src).unwrap();
            let printed = calc_ast_to_css_string(&ast);
            let inner = &printed[5..printed.len() - 1];
            let reparsed = parse_calc_expression(inner).unwrap();
            assert_eq!(
                ast.as_slice(),
                reparsed.as_slice(),
                "round-trip mismatch for {src:?} (printed as {printed:?})"
            );
        }
    }
    #[test]
    fn calc_ast_to_css_string_is_lossy_for_adjacent_values() {
        // ADVERSARIAL: the printer joins items with a single space and adds no
        // disambiguating parens, so an AST holding two adjacent Values — reachable via the
        // FFI/api constructors, though not via parse_calc_expression — prints to CSS that
        // re-parses as a *subtraction*. The encoding is not injective.
        let items = CalcAstItemVec::from_vec(vec![
            CalcAstItem::Value(PixelValue::px(1.0)),
            CalcAstItem::Value(PixelValue::px(-1.0)),
        ]);
        let printed = calc_ast_to_css_string(&items);
        assert_eq!(printed, "calc(1px -1px)");
        let reparsed = parse_calc_expression(&printed[5..printed.len() - 1]).unwrap();
        assert_eq!(shape(&items), vec![0, 0]);
        assert_eq!(shape(&reparsed), vec![0, 2, 0]); // Value, Sub, Value — not the input!
        assert_ne!(items.as_slice(), reparsed.as_slice());
    }
    #[test]
    fn calc_ast_to_css_string_handles_a_huge_ast() {
        let items = CalcAstItemVec::from_vec(vec![CalcAstItem::BraceOpen; 100_000]);
        let printed = calc_ast_to_css_string(&items);
        // 100_000 "(" joined by 100_000 - 1 spaces, plus "calc(" and ")"
        assert_eq!(printed.len(), 100_000 * 2 - 1 + 6);
    }
    // ---------------------------------------------------------------------
    // parse_layout_box_sizing + LayoutBoxSizingParseError round-trips
    // ---------------------------------------------------------------------
    #[test]
    fn box_sizing_valid_inputs_and_trimming() {
        assert_eq!(
            parse_layout_box_sizing("content-box").unwrap(),
            LayoutBoxSizing::ContentBox
        );
        assert_eq!(
            parse_layout_box_sizing("border-box").unwrap(),
            LayoutBoxSizing::BorderBox
        );
        assert_eq!(
            parse_layout_box_sizing("\t\n  border-box \r\n ").unwrap(),
            LayoutBoxSizing::BorderBox
        );
        // encode == decode
        for v in [LayoutBoxSizing::ContentBox, LayoutBoxSizing::BorderBox] {
            assert_eq!(parse_layout_box_sizing(&v.print_as_css_value()).unwrap(), v);
        }
    }
    #[test]
    fn box_sizing_empty_whitespace_and_garbage_are_err() {
        for input in [
            "",
            "   ",
            "\t\n",
            "padding-box",
            "borderbox",
            "border box",
            "content-box;",
            "content-box border-box",
            "content-box!",
            "\0",
            "-",
            "\u{1F600}",
            "content-box\u{0301}",
            "cöntent-box",
            "content\u{2010}box", // unicode hyphen, not ASCII '-'
        ] {
            assert!(
                parse_layout_box_sizing(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    #[test]
    fn box_sizing_rejects_numeric_boundary_strings() {
        for input in [
            "0",
            "-0",
            "NaN",
            "inf",
            "9223372036854775807",
            "-9223372036854775808",
            "3.4028235e38",
            "1e-45",
        ] {
            assert!(
                parse_layout_box_sizing(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    #[test]
    fn box_sizing_keyword_matching_is_case_sensitive() {
        // CSS keywords are ASCII case-insensitive per spec; this parser is not.
        // Documenting the current behaviour so a future fix has to update this test.
        assert!(parse_layout_box_sizing("Content-Box").is_err());
        assert!(parse_layout_box_sizing("BORDER-BOX").is_err());
    }
    #[test]
    fn box_sizing_extremely_long_input_is_err_and_terminates() {
        let long = "a".repeat(1_000_000);
        assert!(parse_layout_box_sizing(&long).is_err());
        // A long *valid* keyword surrounded by whitespace still trims down to Ok.
        let padded = format!("{}border-box{}", " ".repeat(100_000), " ".repeat(100_000));
        assert_eq!(
            parse_layout_box_sizing(&padded).unwrap(),
            LayoutBoxSizing::BorderBox
        );
    }
    #[test]
    fn box_sizing_error_payload_is_the_trimmed_input() {
        let err = parse_layout_box_sizing("  bogus  ").unwrap_err();
        match &err {
            LayoutBoxSizingParseError::InvalidValue(s) => assert_eq!(*s, "bogus"),
        }
        assert_eq!(format!("{err}"), "Invalid box-sizing value: \"bogus\"");
    }
    #[test]
    fn box_sizing_error_to_contained_to_shared_roundtrip() {
        let err = parse_layout_box_sizing("padding-box").unwrap_err();
        let owned = err.to_contained();
        assert_eq!(
            owned,
            LayoutBoxSizingParseErrorOwned::InvalidValue("padding-box".to_string().into())
        );
        // to_shared() must reproduce exactly what to_contained() consumed.
        assert_eq!(owned.to_shared(), err);
        // ...and be idempotent under repeated round-trips.
        assert_eq!(owned.to_shared().to_contained(), owned);
    }
    #[test]
    fn box_sizing_error_roundtrip_with_empty_and_unicode_payloads() {
        for input in ["", "   ", "\u{1F600}\u{0301}é", "a\0b"] {
            let err = parse_layout_box_sizing(input).unwrap_err();
            let owned = err.to_contained();
            assert_eq!(owned.to_shared(), err, "round-trip failed for {input:?}");
            match &owned {
                LayoutBoxSizingParseErrorOwned::InvalidValue(s) => {
                    assert_eq!(s.as_str(), input.trim());
                }
            }
        }
    }
    #[test]
    fn box_sizing_error_roundtrip_with_a_huge_payload() {
        let long = "x".repeat(200_000);
        let err = parse_layout_box_sizing(&long).unwrap_err();
        let owned = err.to_contained();
        match &owned {
            LayoutBoxSizingParseErrorOwned::InvalidValue(s) => {
                assert_eq!(s.as_str().len(), 200_000);
            }
        }
        assert_eq!(owned.to_shared(), err);
    }
    // ---------------------------------------------------------------------
    // The sizing parsers — the only public path into the calc()/fit-content() slicing
    // ---------------------------------------------------------------------
    #[test]
    fn sizing_parser_paren_slicing_is_panic_free() {
        // Both branches slice with hard-coded byte offsets (`s[5..len-1]`, `s[12..len-1]`).
        // These inputs are the ones that would trip an off-by-one or a char-boundary bug.
        for input in [
            "calc()",
            "fit-content()",
            "fit-content(\u{1F600})",
            "calc(\u{1F600})",
            "calc(é)",
            "fit-content(é)",
            "calc( )",
            "fit-content( )",
            "calc(1px)garbage)",
            "fit-content(1px)garbage)",
            "fit-content(1px",
            "calc(1px",
        ] {
            // Only requirement: no panic. (All of these must also be Err.)
            assert!(
                parse_layout_width(input).is_err(),
                "expected Err for {input:?}"
            );
            assert!(
                parse_layout_height(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    #[test]
    fn sizing_parser_keywords_and_calc_roundtrip() {
        let cases = [
            (LayoutWidth::Auto, "auto"),
            (LayoutWidth::MinContent, "min-content"),
            (LayoutWidth::MaxContent, "max-content"),
            (LayoutWidth::Px(PixelValue::px(150.0)), "150px"),
            (LayoutWidth::FitContent(PixelValue::percent(50.0)), "fit-content(50%)"),
        ];
        for (value, css) in cases {
            assert_eq!(parse_layout_width(css).unwrap(), value, "parse of {css:?}");
            assert_eq!(value.print_as_css_value(), css, "print of {css:?}");
        }
        // calc() survives the full encode/decode cycle.
        let parsed = parse_layout_width("calc(100% - 20px)").unwrap();
        assert_eq!(parsed.print_as_css_value(), "calc(100% - 20px)");
        assert_eq!(parse_layout_width(&parsed.print_as_css_value()).unwrap(), parsed);
        assert_eq!(
            calc_items(&parsed),
            vec![
                CalcAstItem::Value(PixelValue::percent(100.0)),
                CalcAstItem::Sub,
                CalcAstItem::Value(PixelValue::px(20.0)),
            ]
        );
    }
    #[test]
    fn sizing_parser_keywords_are_case_sensitive() {
        // Same spec deviation as box-sizing: CSS keywords should be case-insensitive.
        for input in ["AUTO", "Auto", "MIN-CONTENT", "CALC(1px)", "FIT-CONTENT(1px)"] {
            assert!(
                parse_layout_width(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    #[test]
    fn fit_content_clamps_negative_values_to_zero() {
        // Documented invariant of the parser: fit-content() can never be negative.
        assert_eq!(
            parse_layout_width("fit-content(-10px)").unwrap(),
            LayoutWidth::FitContent(PixelValue::zero())
        );
        assert_eq!(
            parse_layout_height("fit-content(-99999%)").unwrap(),
            LayoutHeight::FitContent(PixelValue::zero())
        );
        // NaN quantises to 0, which is >= 0.0, so it takes the non-negative branch.
        assert_eq!(
            parse_layout_width("fit-content(NaN)").unwrap(),
            LayoutWidth::FitContent(PixelValue::zero())
        );
        // A huge value is kept, but saturated to a finite number.
        match parse_layout_width("fit-content(99999999999999999999999999999999999999999px)")
            .unwrap()
        {
            LayoutWidth::FitContent(v) => assert!(v.number.get().is_finite()),
            other => panic!("expected FitContent, got {other:?}"),
        }
    }
    #[test]
    fn sizing_parser_deeply_nested_calc_does_not_stack_overflow() {
        const DEPTH: usize = 5_000;
        let input = format!(
            "calc({}1px{})",
            "(".repeat(DEPTH),
            ")".repeat(DEPTH)
        );
        let parsed = parse_layout_width(&input).unwrap();
        assert_eq!(calc_items(&parsed).len(), DEPTH * 2 + 1);
        // Printing must survive it too (this is what the CSS serialiser calls).
        let printed = parsed.print_as_css_value();
        assert_eq!(printed.matches('(').count(), DEPTH + 1);
        assert_eq!(printed.matches(')').count(), DEPTH + 1);
    }
    #[test]
    fn sizing_parser_error_carries_the_untrimmed_input_for_bad_calc() {
        // Quirk worth pinning: InvalidKeyword gets the *raw* `input`, not the trimmed
        // string (unlike box-sizing, which reports the trimmed value).
        let err = parse_layout_width("  calc(??)  ").unwrap_err();
        match &err {
            LayoutWidthParseError::InvalidKeyword(k) => assert_eq!(*k, "  calc(??)  "),
            other => panic!("expected InvalidKeyword, got {other:?}"),
        }
        // ...and it round-trips through the owned representation unchanged.
        let owned = err.to_contained();
        assert_eq!(owned.to_shared(), err);
    }
    #[test]
    fn pixel_dimension_parser_errors_roundtrip() {
        for input in ["", "   ", "px", "garbage", "\u{1F600}", "1.2.3px"] {
            let err = parse_layout_min_width(input)
                .err()
                .unwrap_or_else(|| panic!("expected Err for {input:?}"));
            let owned = err.to_contained();
            assert_eq!(owned.to_shared(), err, "for {input:?}");
            assert!(parse_layout_max_height(input).is_err(), "for {input:?}");
        }
        assert_eq!(
            parse_layout_min_width("0").unwrap(),
            LayoutMinWidth {
                inner: PixelValue::px(0.0)
            }
        );
    }
    // ---------------------------------------------------------------------
    // Defaults / numeric invariants
    // ---------------------------------------------------------------------
    #[test]
    fn max_dimension_defaults_are_finite_but_not_actually_f32_max() {
        // The doc comment says the default is `f32::MAX` pixels. It isn't: FloatValue
        // stores number * 1000 in an isize, and `f32::MAX * 1000.0` = inf saturates to
        // isize::MAX — so `get()` comes back as ~9.2e15, not 3.4e38. The sentinel is still
        // "effectively unconstrained" and, importantly, finite (so the solver's
        // padding/margin additions cannot reach inf) — but it is NOT f32::MAX.
        for got in [
            LayoutMaxWidth::default().inner.number.get(),
            LayoutMaxHeight::default().inner.number.get(),
        ] {
            assert!(got.is_finite(), "default max dimension is not finite: {got}");
            assert!(got > 0.0);
            assert_ne!(got, f32::MAX);
        }
        // The min-* defaults are exactly zero.
        assert_eq!(LayoutMinWidth::default().inner.number.get(), 0.0);
        assert_eq!(LayoutMinHeight::default().inner.number.get(), 0.0);
        assert_eq!(LayoutWidth::default(), LayoutWidth::Auto);
        assert_eq!(LayoutHeight::default(), LayoutHeight::Auto);
        assert_eq!(LayoutBoxSizing::default(), LayoutBoxSizing::ContentBox);
    }
    #[test]
    fn sizing_interpolate_endpoints_and_nan_t() {
        let a = LayoutWidth::px(10.0);
        let b = LayoutWidth::px(20.0);
        assert_eq!(a.interpolate(&b, 0.0), a);
        assert_eq!(a.interpolate(&b, 1.0), b);
        assert_eq!(a.interpolate(&b, 0.5), LayoutWidth::px(15.0));
        // NaN `t` must not panic and must not leak a NaN into the value.
        match a.interpolate(&b, f32::NAN) {
            LayoutWidth::Px(v) => {
                assert!(!v.number.get().is_nan());
                assert_eq!(v.number.get(), 0.0);
            }
            other => panic!("expected Px, got {other:?}"),
        }
        // Discrete keywords snap rather than blend, and NaN falls through to `other`.
        let auto = LayoutWidth::Auto;
        let min = LayoutWidth::MinContent;
        assert_eq!(auto.interpolate(&min, 0.0), LayoutWidth::Auto);
        assert_eq!(auto.interpolate(&min, 1.0), LayoutWidth::MinContent);
        assert_eq!(auto.interpolate(&min, f32::NAN), LayoutWidth::MinContent);
        // Interpolating a calc() clones the AST (no double-free, no panic).
        let calc = parse_layout_width("calc(100% - 20px)").unwrap();
        assert_eq!(calc.interpolate(&auto, 0.0), calc);
        assert_eq!(auto.interpolate(&calc, 1.0), calc);
    }
    #[test]
    fn sizing_parser_px_quantisation_limits() {
        // Sub-0.001 collapses to zero...
        match parse_layout_width("0.0005px").unwrap() {
            LayoutWidth::Px(v) => assert_eq!(v.number.get(), 0.0),
            other => panic!("expected Px, got {other:?}"),
        }
        // ...and an out-of-f32-range literal saturates to a finite value rather than inf.
        match parse_layout_width(&format!("{}px", "9".repeat(60))).unwrap() {
            LayoutWidth::Px(v) => assert!(v.number.get().is_finite()),
            other => panic!("expected Px, got {other:?}"),
        }
        // A bare NaN literal is accepted as a length and quantises to 0.
        match parse_layout_width("NaN").unwrap() {
            LayoutWidth::Px(v) => assert_eq!(v.number.get(), 0.0),
            other => panic!("expected Px, got {other:?}"),
        }
    }
}