Skip to main content

azul_layout/solver3/
calc.rs

1//! CSS `calc()` expression evaluator.
2//!
3//! This module implements a two-pass stack-machine evaluator for `calc()` expressions.
4//! It resolves `CalcAstItem` slices (flat, parenthesised AST) into a single `f32` pixel value.
5//!
6//! **Resolution context**: Em/rem units are resolved using per-node font sizes that are
7//! captured lazily during style translation and stored alongside the AST pointer passed
8//! to taffy. Percentages use the `basis` value provided by taffy (container width/height).
9
10use azul_css::props::{
11    basic::{
12        pixel::PT_TO_PX,
13        PixelValue, SizeMetric,
14    },
15    layout::dimensions::{CalcAstItem, CalcAstItemVec},
16};
17
18/// CSS reference pixels per inch (96 px/in per CSS spec).
19pub(super) const PX_PER_INCH: f32 = 96.0;
20/// Centimetres per inch.
21pub(super) const CM_PER_INCH: f32 = 2.54;
22/// Millimetres per inch.
23pub(super) const MM_PER_INCH: f32 = 25.4;
24
25/// Font-size context captured at style-translation time and stored alongside the calc AST.
26///
27/// Taffy's `resolve_calc_value` callback only receives `(*const (), f32)` — no node id.
28/// We therefore bundle the per-node font sizes into the heap-pinned data that the opaque
29/// pointer references, so the evaluator can resolve `em` / `rem` correctly.
30#[derive(Debug, Clone)]
31#[repr(C)]
32pub struct CalcResolveContext {
33    /// The calc AST items (flat stack-machine representation).
34    pub items: CalcAstItemVec,
35    /// Element's computed `font-size` in px — used for `em` resolution.
36    pub em_size: f32,
37    /// Root element's computed `font-size` in px — used for `rem` resolution.
38    pub rem_size: f32,
39}
40// Tiny enum (Num(f32) = 4B vs Op(CalcOp) = 1B); the "large" variant is a bare
41// f32, so boxing it would add a pointer + heap allocation — strictly worse than
42// the few bytes of size disparity. Accepted.
43#[allow(variant_size_differences)]
44/// Internal intermediate representation: a number or an operator (after value resolution).
45#[derive(Clone, Debug)]
46enum CalcFlatItem {
47    Num(f32),
48    Op(CalcOp),
49}
50
51/// Arithmetic operators.
52#[derive(Clone, Copy, Debug, PartialEq)]
53enum CalcOp {
54    Add,
55    Sub,
56    Mul,
57    Div,
58}
59
60/// Evaluate a `CalcResolveContext` using the given `basis` (the "100 %" reference value,
61/// e.g. containing-block width for `width: calc(…)`).
62#[inline(never)] // M12.7: keep out of calc_used_size — its loop/jump-table inlined into the huge fn forces a remill PC-dispatch loop that mis-delivers auto_w
63#[must_use] pub fn evaluate_calc(ctx: &CalcResolveContext, basis: f32) -> f32 {
64    evaluate_calc_ast(ctx.items.as_slice(), basis, ctx.em_size, ctx.rem_size)
65}
66
67/// Stack-machine evaluator for a flat `CalcAstItem` slice.
68///
69/// `basis`    — the "100 %" reference value (e.g. containing-block width).
70/// `em_size`  — element's computed font-size (for `em`).
71/// `rem_size` — root element's computed font-size (for `rem`).
72///
73/// Two-pass approach with correct operator precedence:
74///   Pass 1: evaluate `*` and `/`
75///   Pass 2: evaluate `+` and `-`
76/// Parenthesised sub-expressions are resolved recursively.
77#[inline(never)] // M12.7: keep out of calc_used_size — its loop/jump-table inlined into the huge fn forces a remill PC-dispatch loop that mis-delivers auto_w
78fn evaluate_calc_ast(
79    items: &[CalcAstItem],
80    basis: f32,
81    em_size: f32,
82    rem_size: f32,
83) -> f32 {
84    // Convert into a working vec of resolved numbers and operators.
85    let mut flat: Vec<CalcFlatItem> = Vec::with_capacity(items.len());
86    let mut i = 0;
87    while i < items.len() {
88        match &items[i] {
89            CalcAstItem::Value(pv) => {
90                flat.push(CalcFlatItem::Num(resolve_pixel_value(
91                    pv, basis, em_size, rem_size,
92                )));
93            }
94            CalcAstItem::Add => flat.push(CalcFlatItem::Op(CalcOp::Add)),
95            CalcAstItem::Sub => flat.push(CalcFlatItem::Op(CalcOp::Sub)),
96            CalcAstItem::Mul => flat.push(CalcFlatItem::Op(CalcOp::Mul)),
97            CalcAstItem::Div => flat.push(CalcFlatItem::Op(CalcOp::Div)),
98            CalcAstItem::BraceOpen => {
99                // Find matching BraceClose and recurse
100                let start = i + 1;
101                let mut depth = 1u32;
102                let mut j = start;
103                while j < items.len() && depth > 0 {
104                    match &items[j] {
105                        CalcAstItem::BraceOpen => depth += 1,
106                        CalcAstItem::BraceClose => depth -= 1,
107                        _ => {}
108                    }
109                    if depth > 0 {
110                        j += 1;
111                    }
112                }
113                // items[start..j] is the inner sub-expression (excl. braces)
114                let sub_val = evaluate_calc_ast(&items[start..j], basis, em_size, rem_size);
115                flat.push(CalcFlatItem::Num(sub_val));
116                i = j; // skip past the closing brace
117            }
118            CalcAstItem::BraceClose => { /* shouldn't happen at top level */ }
119        }
120        i += 1;
121    }
122
123    // Pass 1: resolve * and /
124    let mut pass2: Vec<CalcFlatItem> = Vec::with_capacity(flat.len());
125    let mut k = 0;
126    while k < flat.len() {
127        if let CalcFlatItem::Op(op @ (CalcOp::Mul | CalcOp::Div)) = &flat[k] {
128            // Apply to previous Num in pass2 and next Num in flat
129            if let (Some(CalcFlatItem::Num(lhs)), Some(CalcFlatItem::Num(rhs))) =
130                (pass2.last(), flat.get(k + 1))
131            {
132                let result = match op {
133                    CalcOp::Mul => lhs * rhs,
134                    CalcOp::Div => {
135                        if *rhs == 0.0 {
136                            0.0
137                        } else {
138                            lhs / rhs
139                        }
140                    }
141                    _ => unreachable!(),
142                };
143                *pass2.last_mut().unwrap() = CalcFlatItem::Num(result);
144                k += 2; // skip operator + rhs
145                continue;
146            }
147        }
148        pass2.push(flat[k].clone());
149        k += 1;
150    }
151
152    // Pass 2: resolve + and -
153    let mut result = match pass2.first() {
154        Some(CalcFlatItem::Num(v)) => *v,
155        _ => return 0.0,
156    };
157    let mut m = 1;
158    while m < pass2.len() {
159        if let (CalcFlatItem::Op(op), Some(CalcFlatItem::Num(rhs))) =
160            (&pass2[m], pass2.get(m + 1))
161        {
162            match op {
163                CalcOp::Add => result += rhs,
164                CalcOp::Sub => result -= rhs,
165                _ => {} // already handled in pass 1
166            }
167            m += 2;
168        } else {
169            m += 1;
170        }
171    }
172
173    result
174}
175
176/// Resolve a single `PixelValue` to `f32` pixels inside a `calc()` expression.
177///
178/// - `basis`    — the "100 %" reference (containing-block width or height)
179/// - `em_size`  — element's computed font-size (for `em` units)
180/// - `rem_size` — root element's computed font-size (for `rem` units)
181///
182/// NOTE: this variant has no viewport context, so viewport units (`vw`/`vh`/
183/// `vmin`/`vmax`) fall back to their raw number (i.e. `50vw` → `50px`). Callers
184/// that may see viewport units must use [`resolve_pixel_value_with_viewport`]
185/// (or [`resolve_pixel_value_no_percent_with_viewport`]) instead.
186#[must_use] pub fn resolve_pixel_value(
187    pv: &PixelValue,
188    basis: f32,
189    em_size: f32,
190    rem_size: f32,
191) -> f32 {
192    match pv.metric {
193        SizeMetric::Px => pv.number.get(),
194        SizeMetric::Pt => pv.number.get() * PT_TO_PX,
195        SizeMetric::In => pv.number.get() * PX_PER_INCH,
196        SizeMetric::Cm => pv.number.get() * PX_PER_INCH / CM_PER_INCH,
197        SizeMetric::Mm => pv.number.get() * PX_PER_INCH / MM_PER_INCH,
198        SizeMetric::Em => pv.number.get() * em_size,
199        SizeMetric::Rem => pv.number.get() * rem_size,
200        SizeMetric::Percent => basis * (pv.number.get() / 100.0),
201        SizeMetric::Vw | SizeMetric::Vh | SizeMetric::Vmin | SizeMetric::Vmax => {
202            // Viewport units: fallback — proper resolution requires viewport context
203            pv.number.get()
204        }
205    }
206}
207
208/// Like `resolve_pixel_value`, but with proper viewport unit resolution.
209#[inline(never)] // M12.7: keep out of calc_used_size — its loop/jump-table inlined into the huge fn forces a remill PC-dispatch loop that mis-delivers auto_w
210#[must_use] pub fn resolve_pixel_value_with_viewport(
211    pv: &PixelValue,
212    basis: f32,
213    em_size: f32,
214    rem_size: f32,
215    viewport_width: f32,
216    viewport_height: f32,
217) -> f32 {
218    match pv.metric {
219        SizeMetric::Vw => pv.number.get() / 100.0 * viewport_width,
220        SizeMetric::Vh => pv.number.get() / 100.0 * viewport_height,
221        SizeMetric::Vmin => pv.number.get() / 100.0 * viewport_width.min(viewport_height),
222        SizeMetric::Vmax => pv.number.get() / 100.0 * viewport_width.max(viewport_height),
223        _ => resolve_pixel_value(pv, basis, em_size, rem_size),
224    }
225}
226
227/// Resolve a `PixelValue` to pixels, returning `None` for percentage and viewport units.
228#[must_use] pub fn resolve_pixel_value_no_percent(
229    pv: &PixelValue,
230    em_size: f32,
231    rem_size: f32,
232) -> Option<f32> {
233    match pv.metric {
234        SizeMetric::Px => Some(pv.number.get()),
235        SizeMetric::Pt => Some(pv.number.get() * PT_TO_PX),
236        SizeMetric::In => Some(pv.number.get() * PX_PER_INCH),
237        SizeMetric::Cm => Some(pv.number.get() * PX_PER_INCH / CM_PER_INCH),
238        SizeMetric::Mm => Some(pv.number.get() * PX_PER_INCH / MM_PER_INCH),
239        SizeMetric::Em => Some(pv.number.get() * em_size),
240        SizeMetric::Rem => Some(pv.number.get() * rem_size),
241        SizeMetric::Percent
242        | SizeMetric::Vw
243        | SizeMetric::Vh
244        | SizeMetric::Vmin
245        | SizeMetric::Vmax => None,
246    }
247}
248
249/// Like `resolve_pixel_value_no_percent`, but resolves viewport units using
250/// the provided viewport dimensions. Returns `None` only for percentages.
251#[must_use] pub fn resolve_pixel_value_no_percent_with_viewport(
252    pv: &PixelValue,
253    em_size: f32,
254    rem_size: f32,
255    viewport_width: f32,
256    viewport_height: f32,
257) -> Option<f32> {
258    match pv.metric {
259        SizeMetric::Vw => Some(pv.number.get() / 100.0 * viewport_width),
260        SizeMetric::Vh => Some(pv.number.get() / 100.0 * viewport_height),
261        SizeMetric::Vmin => Some(pv.number.get() / 100.0 * viewport_width.min(viewport_height)),
262        SizeMetric::Vmax => Some(pv.number.get() / 100.0 * viewport_width.max(viewport_height)),
263        _ => resolve_pixel_value_no_percent(pv, em_size, rem_size),
264    }
265}
266
267#[cfg(test)]
268#[allow(clippy::float_cmp, clippy::cast_precision_loss, clippy::unreadable_literal)]
269mod autotest_generated {
270    use azul_css::props::basic::FP_PRECISION_MULTIPLIER;
271
272    use super::*;
273
274    // ------------------------------------------------------------------
275    // Fixtures
276    // ------------------------------------------------------------------
277
278    /// Every `SizeMetric` variant, so "for all metrics" invariants stay honest
279    /// if a new unit is added to the enum (the array length breaks the build).
280    const ALL_METRICS: [SizeMetric; 12] = [
281        SizeMetric::Px,
282        SizeMetric::Pt,
283        SizeMetric::Em,
284        SizeMetric::Rem,
285        SizeMetric::In,
286        SizeMetric::Cm,
287        SizeMetric::Mm,
288        SizeMetric::Percent,
289        SizeMetric::Vw,
290        SizeMetric::Vh,
291        SizeMetric::Vmin,
292        SizeMetric::Vmax,
293    ];
294
295    /// Metrics that resolve to an absolute length without any layout context.
296    const ABSOLUTE_METRICS: [SizeMetric; 7] = [
297        SizeMetric::Px,
298        SizeMetric::Pt,
299        SizeMetric::Em,
300        SizeMetric::Rem,
301        SizeMetric::In,
302        SizeMetric::Cm,
303        SizeMetric::Mm,
304    ];
305
306    const VIEWPORT_METRICS: [SizeMetric; 4] = [
307        SizeMetric::Vw,
308        SizeMetric::Vh,
309        SizeMetric::Vmin,
310        SizeMetric::Vmax,
311    ];
312
313    /// `FloatValue` is fixed-point (`isize` scaled by `FP_PRECISION_MULTIPLIER`),
314    /// so any `f32` too large to fit saturates to this magnitude on `get()`.
315    fn saturated_magnitude() -> f32 {
316        (isize::MAX as f32) / FP_PRECISION_MULTIPLIER
317    }
318
319    fn assert_close(actual: f32, expected: f32) {
320        let tolerance = 1e-3 * expected.abs().max(1.0);
321        assert!(
322            (actual - expected).abs() <= tolerance,
323            "expected ~{expected}, got {actual}",
324        );
325    }
326
327    fn val(metric: SizeMetric, n: f32) -> CalcAstItem {
328        CalcAstItem::Value(PixelValue::from_metric(metric, n))
329    }
330
331    fn px(n: f32) -> CalcAstItem {
332        val(SizeMetric::Px, n)
333    }
334
335    const EM: f32 = 16.0;
336    const REM: f32 = 10.0;
337
338    fn eval(items: &[CalcAstItem], basis: f32) -> f32 {
339        evaluate_calc_ast(items, basis, EM, REM)
340    }
341
342    fn ctx(items: Vec<CalcAstItem>, em_size: f32, rem_size: f32) -> CalcResolveContext {
343        CalcResolveContext {
344            items: CalcAstItemVec::from(items),
345            em_size,
346            rem_size,
347        }
348    }
349
350    // ==================================================================
351    // PixelValue encoding invariants
352    //
353    // These underpin every resolver below: because `FloatValue` stores an
354    // `isize` and `f32 as isize` saturates (NaN -> 0, +/-inf -> MIN/MAX),
355    // a `PixelValue` can never *carry* a NaN or an infinity. Any non-finite
356    // result therefore has to come from `basis` / `em_size` / `viewport_*`.
357    // ==================================================================
358
359    #[test]
360    fn pixel_value_number_is_always_finite_even_for_nan_and_inf() {
361        for metric in ALL_METRICS {
362            for hostile in [
363                f32::NAN,
364                f32::INFINITY,
365                f32::NEG_INFINITY,
366                f32::MAX,
367                f32::MIN,
368                f32::MIN_POSITIVE,
369                -0.0,
370            ] {
371                let n = PixelValue::from_metric(metric, hostile).number.get();
372                assert!(
373                    n.is_finite(),
374                    "PixelValue::from_metric({metric:?}, {hostile}) produced non-finite {n}",
375                );
376            }
377        }
378    }
379
380    #[test]
381    fn pixel_value_nan_encodes_as_zero() {
382        assert_eq!(PixelValue::px(f32::NAN).number.get(), 0.0);
383    }
384
385    #[test]
386    fn pixel_value_infinities_saturate_to_fixed_point_bounds() {
387        let sat = saturated_magnitude();
388        assert_close(PixelValue::px(f32::INFINITY).number.get(), sat);
389        assert_close(PixelValue::px(f32::NEG_INFINITY).number.get(), -sat);
390        // f32::MAX * 1000.0 already overflows to inf before the cast, so it
391        // lands on the same saturation point rather than on f32::MAX.
392        assert_close(PixelValue::px(f32::MAX).number.get(), sat);
393        assert_close(PixelValue::px(f32::MIN).number.get(), -sat);
394    }
395
396    // ==================================================================
397    // resolve_pixel_value
398    // ==================================================================
399
400    #[test]
401    fn resolve_pixel_value_zero_is_zero_for_every_metric() {
402        for metric in ALL_METRICS {
403            let pv = PixelValue::from_metric(metric, 0.0);
404            let got = resolve_pixel_value(&pv, 500.0, EM, REM);
405            assert_eq!(got, 0.0, "0 {metric:?} should resolve to 0px, got {got}");
406        }
407    }
408
409    #[test]
410    fn resolve_pixel_value_absolute_unit_conversions() {
411        assert_eq!(resolve_pixel_value(&PixelValue::px(10.0), 0.0, EM, REM), 10.0);
412        assert_close(
413            resolve_pixel_value(&PixelValue::pt(12.0), 0.0, EM, REM),
414            12.0 * PT_TO_PX,
415        );
416        // 1in == 96px, 2.54cm == 1in, 25.4mm == 1in.
417        assert_close(resolve_pixel_value(&PixelValue::inch(1.0), 0.0, EM, REM), 96.0);
418        assert_close(resolve_pixel_value(&PixelValue::cm(2.54), 0.0, EM, REM), 96.0);
419        assert_close(resolve_pixel_value(&PixelValue::mm(25.4), 0.0, EM, REM), 96.0);
420    }
421
422    #[test]
423    fn resolve_pixel_value_em_and_rem_use_their_own_font_size() {
424        assert_eq!(resolve_pixel_value(&PixelValue::em(2.0), 0.0, 16.0, 10.0), 32.0);
425        assert_eq!(resolve_pixel_value(&PixelValue::rem(2.0), 0.0, 16.0, 10.0), 20.0);
426    }
427
428    #[test]
429    fn resolve_pixel_value_percent_uses_basis() {
430        assert_eq!(resolve_pixel_value(&PixelValue::percent(50.0), 200.0, EM, REM), 100.0);
431        assert_eq!(resolve_pixel_value(&PixelValue::percent(0.0), 200.0, EM, REM), 0.0);
432        assert_eq!(resolve_pixel_value(&PixelValue::percent(100.0), 0.0, EM, REM), 0.0);
433    }
434
435    #[test]
436    fn resolve_pixel_value_handles_negative_inputs_deterministically() {
437        assert_eq!(resolve_pixel_value(&PixelValue::px(-10.0), 0.0, EM, REM), -10.0);
438        assert_eq!(resolve_pixel_value(&PixelValue::em(-2.0), 0.0, 16.0, REM), -32.0);
439        // Negative percentage of a positive basis, and positive percentage of a
440        // negative basis, both yield a negative length (no clamping here).
441        assert_eq!(resolve_pixel_value(&PixelValue::percent(-50.0), 200.0, EM, REM), -100.0);
442        assert_eq!(resolve_pixel_value(&PixelValue::percent(50.0), -200.0, EM, REM), -100.0);
443    }
444
445    #[test]
446    fn resolve_pixel_value_viewport_units_fall_back_to_raw_number() {
447        // Documented fallback: with no viewport context, `50vw` degrades to `50px`.
448        for metric in VIEWPORT_METRICS {
449            let pv = PixelValue::from_metric(metric, 50.0);
450            assert_eq!(
451                resolve_pixel_value(&pv, 1000.0, EM, REM),
452                50.0,
453                "{metric:?} should fall back to its raw number",
454            );
455        }
456    }
457
458    #[test]
459    fn resolve_pixel_value_is_finite_for_any_pixel_value_in_a_sane_context() {
460        // The core safety property: because a PixelValue can never *carry* a NaN
461        // or an infinity, no value — not even one built from f32::MAX — can turn
462        // a realistic basis/em/rem into a non-finite length. (An absurd basis
463        // *can* still overflow; that is pinned separately below.)
464        for metric in ALL_METRICS {
465            for n in [0.0, -0.0, 1.0, -1.0, f32::MAX, f32::MIN, f32::NAN, f32::INFINITY] {
466                let pv = PixelValue::from_metric(metric, n);
467                for basis in [0.0_f32, -1000.0, 1920.0, 1_000_000.0] {
468                    let got = resolve_pixel_value(&pv, basis, EM, REM);
469                    assert!(
470                        got.is_finite(),
471                        "{metric:?} {n} @ basis {basis} produced non-finite {got}",
472                    );
473                }
474            }
475        }
476    }
477
478    #[test]
479    fn resolve_pixel_value_saturated_percent_of_huge_basis_overflows_to_inf_not_panic() {
480        // 100% of f32::MAX stays finite; 200% of it overflows.
481        let hundred = resolve_pixel_value(&PixelValue::percent(100.0), f32::MAX, EM, REM);
482        assert_eq!(hundred, f32::MAX);
483
484        let two_hundred = resolve_pixel_value(&PixelValue::percent(200.0), f32::MAX, EM, REM);
485        assert!(
486            two_hundred.is_infinite() && two_hundred.is_sign_positive(),
487            "expected +inf on overflow, got {two_hundred}",
488        );
489    }
490
491    #[test]
492    fn resolve_pixel_value_nan_basis_only_pollutes_percentages() {
493        // A NaN basis must not leak into units that never read it.
494        assert_eq!(resolve_pixel_value(&PixelValue::px(10.0), f32::NAN, EM, REM), 10.0);
495        assert_eq!(resolve_pixel_value(&PixelValue::em(1.0), f32::NAN, 16.0, REM), 16.0);
496        assert!(resolve_pixel_value(&PixelValue::percent(50.0), f32::NAN, EM, REM).is_nan());
497    }
498
499    #[test]
500    fn resolve_pixel_value_zero_times_infinite_context_is_nan_not_zero() {
501        // IEEE-754 quirk worth pinning: `0em` against an infinite font-size (and
502        // `0%` against an infinite basis) is 0 * inf == NaN, NOT 0. Callers that
503        // treat "zero length" as always-safe do not get a zero here.
504        assert!(resolve_pixel_value(&PixelValue::em(0.0), 0.0, f32::INFINITY, REM).is_nan());
505        assert!(resolve_pixel_value(&PixelValue::percent(0.0), f32::INFINITY, EM, REM).is_nan());
506        // ...whereas a non-zero multiplier gives a plain infinity.
507        let inf = resolve_pixel_value(&PixelValue::em(1.0), 0.0, f32::INFINITY, REM);
508        assert!(inf.is_infinite() && inf.is_sign_positive());
509    }
510
511    // ==================================================================
512    // resolve_pixel_value_with_viewport
513    // ==================================================================
514
515    #[test]
516    fn resolve_with_viewport_resolves_viewport_units() {
517        let (vw, vh) = (1000.0_f32, 800.0_f32);
518        let r = |m: SizeMetric, n: f32| {
519            resolve_pixel_value_with_viewport(&PixelValue::from_metric(m, n), 0.0, EM, REM, vw, vh)
520        };
521        assert_eq!(r(SizeMetric::Vw, 50.0), 500.0);
522        assert_eq!(r(SizeMetric::Vh, 50.0), 400.0);
523        assert_eq!(r(SizeMetric::Vmin, 100.0), 800.0);
524        assert_eq!(r(SizeMetric::Vmax, 100.0), 1000.0);
525    }
526
527    #[test]
528    fn resolve_with_viewport_delegates_non_viewport_metrics_verbatim() {
529        for metric in ALL_METRICS {
530            if VIEWPORT_METRICS.contains(&metric) {
531                continue;
532            }
533            let pv = PixelValue::from_metric(metric, 33.75);
534            assert_eq!(
535                resolve_pixel_value_with_viewport(&pv, 500.0, EM, REM, 1920.0, 1080.0),
536                resolve_pixel_value(&pv, 500.0, EM, REM),
537                "{metric:?} must not be affected by viewport context",
538            );
539        }
540    }
541
542    #[test]
543    fn resolve_with_viewport_zero_and_negative_viewport() {
544        for metric in VIEWPORT_METRICS {
545            let pv = PixelValue::from_metric(metric, 50.0);
546            assert_eq!(
547                resolve_pixel_value_with_viewport(&pv, 0.0, EM, REM, 0.0, 0.0),
548                0.0,
549                "{metric:?} against a 0x0 viewport should be 0px",
550            );
551        }
552        // A negative viewport is nonsense but must stay deterministic, not panic.
553        let vw50 = PixelValue::from_metric(SizeMetric::Vw, 50.0);
554        assert_eq!(
555            resolve_pixel_value_with_viewport(&vw50, 0.0, EM, REM, -100.0, -100.0),
556            -50.0,
557        );
558        // min/max still order correctly with negatives: min(-100, -50) == -100.
559        let vmin100 = PixelValue::from_metric(SizeMetric::Vmin, 100.0);
560        let vmax100 = PixelValue::from_metric(SizeMetric::Vmax, 100.0);
561        assert_eq!(
562            resolve_pixel_value_with_viewport(&vmin100, 0.0, EM, REM, -100.0, -50.0),
563            -100.0,
564        );
565        assert_eq!(
566            resolve_pixel_value_with_viewport(&vmax100, 0.0, EM, REM, -100.0, -50.0),
567            -50.0,
568        );
569    }
570
571    #[test]
572    fn resolve_with_viewport_nan_viewport_is_swallowed_by_vmin_vmax_but_not_vw_vh() {
573        // f32::min / f32::max return the *other* operand when one side is NaN,
574        // so a half-NaN viewport silently produces a finite vmin/vmax...
575        let vmin = PixelValue::from_metric(SizeMetric::Vmin, 100.0);
576        let vmax = PixelValue::from_metric(SizeMetric::Vmax, 100.0);
577        assert_eq!(
578            resolve_pixel_value_with_viewport(&vmin, 0.0, EM, REM, f32::NAN, 800.0),
579            800.0,
580        );
581        assert_eq!(
582            resolve_pixel_value_with_viewport(&vmax, 0.0, EM, REM, f32::NAN, 800.0),
583            800.0,
584        );
585        // ...while vw/vh propagate the NaN. Both are defined; neither panics.
586        let vw = PixelValue::from_metric(SizeMetric::Vw, 50.0);
587        assert!(resolve_pixel_value_with_viewport(&vw, 0.0, EM, REM, f32::NAN, 800.0).is_nan());
588
589        // Fully-NaN viewport: vmin/vmax have no finite operand to fall back on.
590        assert!(
591            resolve_pixel_value_with_viewport(&vmin, 0.0, EM, REM, f32::NAN, f32::NAN).is_nan()
592        );
593    }
594
595    #[test]
596    fn resolve_with_viewport_infinite_viewport_does_not_panic() {
597        let vw = PixelValue::from_metric(SizeMetric::Vw, 50.0);
598        let got = resolve_pixel_value_with_viewport(&vw, 0.0, EM, REM, f32::INFINITY, 0.0);
599        assert!(got.is_infinite() && got.is_sign_positive());
600
601        // 0vw of an infinite viewport is 0 * inf == NaN, same quirk as `0em`.
602        let vw0 = PixelValue::from_metric(SizeMetric::Vw, 0.0);
603        assert!(
604            resolve_pixel_value_with_viewport(&vw0, 0.0, EM, REM, f32::INFINITY, 0.0).is_nan()
605        );
606    }
607
608    // ==================================================================
609    // resolve_pixel_value_no_percent (+ viewport variant)
610    // ==================================================================
611
612    #[test]
613    fn no_percent_returns_none_for_percent_and_viewport_units() {
614        for metric in [
615            SizeMetric::Percent,
616            SizeMetric::Vw,
617            SizeMetric::Vh,
618            SizeMetric::Vmin,
619            SizeMetric::Vmax,
620        ] {
621            let pv = PixelValue::from_metric(metric, 50.0);
622            assert_eq!(
623                resolve_pixel_value_no_percent(&pv, EM, REM),
624                None,
625                "{metric:?} needs layout context, so it must be None",
626            );
627        }
628    }
629
630    #[test]
631    fn no_percent_agrees_with_resolve_pixel_value_on_absolute_metrics() {
632        // Invariant: where `no_percent` returns Some, the value must be exactly
633        // what `resolve_pixel_value` computes — for *any* basis, since none of
634        // these units read it.
635        for metric in ABSOLUTE_METRICS {
636            for n in [0.0, 1.0, -7.5, 1234.5, f32::MAX, f32::MIN, f32::NAN] {
637                let pv = PixelValue::from_metric(metric, n);
638                let some = resolve_pixel_value_no_percent(&pv, EM, REM);
639                assert_eq!(
640                    some,
641                    Some(resolve_pixel_value(&pv, 999.0, EM, REM)),
642                    "{metric:?} {n} disagrees between the two resolvers",
643                );
644                assert!(some.unwrap().is_finite());
645            }
646        }
647    }
648
649    #[test]
650    fn no_percent_with_viewport_returns_none_only_for_percent() {
651        for metric in ALL_METRICS {
652            let pv = PixelValue::from_metric(metric, 50.0);
653            let got = resolve_pixel_value_no_percent_with_viewport(&pv, EM, REM, 1000.0, 800.0);
654            if metric == SizeMetric::Percent {
655                assert_eq!(got, None, "% must stay None without a basis");
656            } else {
657                assert!(got.is_some(), "{metric:?} should resolve with a viewport");
658                assert!(got.unwrap().is_finite());
659            }
660        }
661    }
662
663    #[test]
664    fn no_percent_with_viewport_agrees_with_resolve_with_viewport() {
665        for metric in ALL_METRICS {
666            if metric == SizeMetric::Percent {
667                continue;
668            }
669            let pv = PixelValue::from_metric(metric, 25.0);
670            assert_eq!(
671                resolve_pixel_value_no_percent_with_viewport(&pv, EM, REM, 1920.0, 1080.0),
672                Some(resolve_pixel_value_with_viewport(&pv, 0.0, EM, REM, 1920.0, 1080.0)),
673                "{metric:?} disagrees between the viewport resolvers",
674            );
675        }
676    }
677
678    #[test]
679    fn no_percent_with_viewport_zero_viewport_yields_some_zero_not_none() {
680        for metric in VIEWPORT_METRICS {
681            let pv = PixelValue::from_metric(metric, 100.0);
682            assert_eq!(
683                resolve_pixel_value_no_percent_with_viewport(&pv, EM, REM, 0.0, 0.0),
684                Some(0.0),
685                "{metric:?} against a 0x0 viewport is a defined 0px, not None",
686            );
687        }
688    }
689
690    // ==================================================================
691    // evaluate_calc_ast — structure & precedence
692    // ==================================================================
693
694    #[test]
695    fn eval_empty_ast_is_zero() {
696        assert_eq!(eval(&[], 500.0), 0.0);
697    }
698
699    #[test]
700    fn eval_single_value() {
701        assert_eq!(eval(&[px(42.0)], 500.0), 42.0);
702        assert_eq!(eval(&[val(SizeMetric::Percent, 10.0)], 500.0), 50.0);
703    }
704
705    #[test]
706    fn eval_basic_addition_and_subtraction() {
707        // calc(100% - 20px) with a 500px containing block
708        let items = [val(SizeMetric::Percent, 100.0), CalcAstItem::Sub, px(20.0)];
709        assert_eq!(eval(&items, 500.0), 480.0);
710    }
711
712    #[test]
713    fn eval_add_and_sub_are_left_associative() {
714        let items = [px(10.0), CalcAstItem::Sub, px(3.0), CalcAstItem::Sub, px(2.0)];
715        assert_eq!(eval(&items, 0.0), 5.0, "must be (10-3)-2, not 10-(3-2)");
716    }
717
718    #[test]
719    fn eval_div_is_left_associative() {
720        let items = [px(10.0), CalcAstItem::Div, px(4.0), CalcAstItem::Div, px(2.0)];
721        assert_eq!(eval(&items, 0.0), 1.25, "must be (10/4)/2, not 10/(4/2)");
722    }
723
724    #[test]
725    fn eval_mul_binds_tighter_than_add() {
726        // 1 + 2 * 3 == 7, not 9
727        let items = [px(1.0), CalcAstItem::Add, px(2.0), CalcAstItem::Mul, px(3.0)];
728        assert_eq!(eval(&items, 0.0), 7.0);
729
730        // 2 * 3 + 4 == 10 (operator on the left of the sum)
731        let items = [px(2.0), CalcAstItem::Mul, px(3.0), CalcAstItem::Add, px(4.0)];
732        assert_eq!(eval(&items, 0.0), 10.0);
733    }
734
735    #[test]
736    fn eval_div_binds_tighter_than_sub() {
737        // 10 - 6 / 2 == 7, not 2
738        let items = [px(10.0), CalcAstItem::Sub, px(6.0), CalcAstItem::Div, px(2.0)];
739        assert_eq!(eval(&items, 0.0), 7.0);
740    }
741
742    #[test]
743    fn eval_braces_override_precedence() {
744        // (1 + 2) * 3 == 9
745        let items = [
746            CalcAstItem::BraceOpen,
747            px(1.0),
748            CalcAstItem::Add,
749            px(2.0),
750            CalcAstItem::BraceClose,
751            CalcAstItem::Mul,
752            px(3.0),
753        ];
754        assert_eq!(eval(&items, 0.0), 9.0);
755    }
756
757    #[test]
758    fn eval_nested_braces() {
759        // ((2 + 3) * (4 - 2)) == 10
760        let items = [
761            CalcAstItem::BraceOpen,
762            CalcAstItem::BraceOpen,
763            px(2.0),
764            CalcAstItem::Add,
765            px(3.0),
766            CalcAstItem::BraceClose,
767            CalcAstItem::Mul,
768            CalcAstItem::BraceOpen,
769            px(4.0),
770            CalcAstItem::Sub,
771            px(2.0),
772            CalcAstItem::BraceClose,
773            CalcAstItem::BraceClose,
774        ];
775        assert_eq!(eval(&items, 0.0), 10.0);
776    }
777
778    #[test]
779    fn eval_empty_braces_are_zero() {
780        let items = [CalcAstItem::BraceOpen, CalcAstItem::BraceClose];
781        assert_eq!(eval(&items, 500.0), 0.0);
782
783        // ...and an empty brace still participates as a term: 10 + () == 10
784        let items = [
785            px(10.0),
786            CalcAstItem::Add,
787            CalcAstItem::BraceOpen,
788            CalcAstItem::BraceClose,
789        ];
790        assert_eq!(eval(&items, 500.0), 10.0);
791    }
792
793    #[test]
794    fn eval_percent_inside_braces_still_sees_the_basis() {
795        // (100% - 20px) / 4  with basis 500 -> 120
796        let items = [
797            CalcAstItem::BraceOpen,
798            val(SizeMetric::Percent, 100.0),
799            CalcAstItem::Sub,
800            px(20.0),
801            CalcAstItem::BraceClose,
802            CalcAstItem::Div,
803            px(4.0),
804        ];
805        assert_eq!(eval(&items, 500.0), 120.0);
806    }
807
808    #[test]
809    fn eval_em_and_rem_come_from_the_evaluator_args_not_the_basis() {
810        // 2em + 1rem with em=10, rem=20 -> 40
811        let items = [val(SizeMetric::Em, 2.0), CalcAstItem::Add, val(SizeMetric::Rem, 1.0)];
812        assert_eq!(evaluate_calc_ast(&items, 0.0, 10.0, 20.0), 40.0);
813    }
814
815    // ==================================================================
816    // evaluate_calc_ast — malformed / adversarial ASTs
817    //
818    // The AST is only ever produced by the parser, but it also crosses an FFI
819    // boundary (`CalcAstItemVec` is `repr(C)`), so a hand-built or corrupted
820    // item list must degrade, never panic or hang.
821    // ==================================================================
822
823    #[test]
824    fn eval_unmatched_brace_open_does_not_panic_or_hang() {
825        // A lone `(` — the scan for the matching `)` runs off the end.
826        assert_eq!(eval(&[CalcAstItem::BraceOpen], 500.0), 0.0);
827        // `(5px` — the unterminated body is still evaluated.
828        assert_eq!(eval(&[CalcAstItem::BraceOpen, px(5.0)], 500.0), 5.0);
829        // `1px + (2px` -> 3
830        let items = [
831            px(1.0),
832            CalcAstItem::Add,
833            CalcAstItem::BraceOpen,
834            px(2.0),
835        ];
836        assert_eq!(eval(&items, 500.0), 3.0);
837    }
838
839    #[test]
840    fn eval_stray_brace_close_is_ignored() {
841        // `5px ) + 3px` -> the stray `)` is skipped at top level.
842        let items = [
843            px(5.0),
844            CalcAstItem::BraceClose,
845            CalcAstItem::Add,
846            px(3.0),
847        ];
848        assert_eq!(eval(&items, 0.0), 8.0);
849
850        // A leading `)` is likewise ignored.
851        assert_eq!(eval(&[CalcAstItem::BraceClose, px(7.0)], 0.0), 7.0);
852    }
853
854    #[test]
855    fn eval_leading_operator_collapses_to_zero() {
856        // Nothing to apply the operator to -> the whole expression is 0.
857        assert_eq!(eval(&[CalcAstItem::Mul, px(5.0)], 0.0), 0.0);
858        assert_eq!(eval(&[CalcAstItem::Add, px(5.0)], 0.0), 0.0);
859        assert_eq!(eval(&[CalcAstItem::Div, px(5.0)], 0.0), 0.0);
860    }
861
862    #[test]
863    fn eval_trailing_operator_keeps_the_left_hand_side() {
864        for op in [
865            CalcAstItem::Add,
866            CalcAstItem::Sub,
867            CalcAstItem::Mul,
868            CalcAstItem::Div,
869        ] {
870            assert_eq!(eval(&[px(10.0), op], 0.0), 10.0, "trailing {op:?} dropped the lhs");
871        }
872    }
873
874    #[test]
875    fn eval_operator_only_ast_is_zero() {
876        let items = [
877            CalcAstItem::Add,
878            CalcAstItem::Sub,
879            CalcAstItem::Mul,
880            CalcAstItem::Div,
881        ];
882        assert_eq!(eval(&items, 500.0), 0.0);
883    }
884
885    #[test]
886    fn eval_adjacent_values_without_an_operator_keep_only_the_first() {
887        // `10px 20px` is not valid calc(); the evaluator silently drops the tail
888        // rather than panicking. Pinning the current behaviour.
889        assert_eq!(eval(&[px(10.0), px(20.0)], 0.0), 10.0);
890    }
891
892    #[test]
893    fn eval_adjacent_operators_do_not_panic() {
894        // `1px + * 2px` — the orphaned `*` survives pass 1 and is skipped in pass 2.
895        let items = [
896            px(1.0),
897            CalcAstItem::Add,
898            CalcAstItem::Mul,
899            px(2.0),
900        ];
901        assert_eq!(eval(&items, 0.0), 1.0);
902    }
903
904    #[test]
905    fn eval_deeply_nested_braces_do_not_overflow_the_stack() {
906        // Brace handling recurses once per nesting level. 256 levels is far past
907        // anything a real stylesheet produces and must still return cleanly.
908        const DEPTH: usize = 256;
909        let mut items: Vec<CalcAstItem> = vec![CalcAstItem::BraceOpen; DEPTH];
910        items.push(px(1.0));
911        items.resize(DEPTH * 2 + 1, CalcAstItem::BraceClose);
912        assert_eq!(eval(&items, 0.0), 1.0);
913    }
914
915    #[test]
916    fn eval_unbalanced_deep_braces_terminate() {
917        // 256 `(` with no `)` at all: every level recurses on a strictly shorter
918        // slice, so this must terminate rather than loop forever.
919        let items = vec![CalcAstItem::BraceOpen; 256];
920        assert_eq!(eval(&items, 0.0), 0.0);
921    }
922
923    #[test]
924    fn eval_long_flat_ast_terminates_with_the_right_sum() {
925        // 1000 terms of `1px + 1px + ...` — guards against a quadratic blow-up
926        // or a non-advancing index in the two passes.
927        const TERMS: usize = 1000;
928        let mut items = Vec::with_capacity(TERMS * 2 - 1);
929        for i in 0..TERMS {
930            if i > 0 {
931                items.push(CalcAstItem::Add);
932            }
933            items.push(px(1.0));
934        }
935        assert_eq!(eval(&items, 0.0), TERMS as f32);
936    }
937
938    // ==================================================================
939    // evaluate_calc_ast — numeric edge cases
940    // ==================================================================
941
942    #[test]
943    fn eval_division_by_zero_is_guarded_to_zero_not_infinity() {
944        let items = [px(10.0), CalcAstItem::Div, px(0.0)];
945        let got = eval(&items, 0.0);
946        assert_eq!(got, 0.0);
947        assert!(got.is_finite(), "10px / 0px must not be an infinity");
948    }
949
950    #[test]
951    fn eval_division_by_a_zero_valued_percent_is_also_guarded() {
952        // The divisor is only known after the basis is applied: 100% of a 0 basis.
953        let items = [px(10.0), CalcAstItem::Div, val(SizeMetric::Percent, 100.0)];
954        assert_eq!(eval(&items, 0.0), 0.0);
955
956        // Negative zero compares equal to 0.0, so it takes the same guard and
957        // yields +0.0 rather than -inf.
958        let got = eval(&items, -0.0);
959        assert_eq!(got, 0.0);
960        assert!(got.is_finite(), "10px / (100% of -0) must not be -inf");
961    }
962
963    #[test]
964    fn eval_division_by_nan_is_not_caught_by_the_zero_guard() {
965        // The guard is `rhs == 0.0`, which NaN fails — so the NaN propagates.
966        // Defined, not a panic, but worth pinning: NaN divisors are NOT sanitised.
967        let items = [px(10.0), CalcAstItem::Div, val(SizeMetric::Percent, 100.0)];
968        assert!(eval(&items, f32::NAN).is_nan());
969    }
970
971    #[test]
972    fn eval_divisor_below_fixed_point_precision_truncates_into_the_zero_guard() {
973        // `FloatValue` keeps 3 decimal places, so 0.0001 encodes to *exactly* 0 —
974        // a divisor that is non-zero in the stylesheet but zero by the time the
975        // evaluator sees it. `calc(10px / 0.0001)` is therefore 0px, not 100000px.
976        assert_eq!(PixelValue::px(0.0001).number.get(), 0.0);
977        let items = [px(10.0), CalcAstItem::Div, px(0.0001)];
978        assert_eq!(eval(&items, 0.0), 0.0);
979
980        // 0.001 is the smallest divisor that survives the encoding.
981        assert_close(PixelValue::px(0.001).number.get(), 0.001);
982        let items = [px(10.0), CalcAstItem::Div, px(0.001)];
983        let got = eval(&items, 0.0);
984        assert!(got.is_finite(), "10px / 0.001px should be finite, got {got}");
985        assert_close(got, 10_000.0);
986    }
987
988    #[test]
989    fn eval_multiplication_overflow_saturates_to_infinity_without_panicking() {
990        // Chain enough saturated values that the product overflows f32 on any
991        // isize width (64-bit *and* 32-bit/wasm).
992        let mut items = vec![px(f32::MAX)];
993        for _ in 0..7 {
994            items.push(CalcAstItem::Mul);
995            items.push(px(f32::MAX));
996        }
997        let got = eval(&items, 0.0);
998        assert!(
999            got.is_infinite() && got.is_sign_positive(),
1000            "expected +inf on overflow, got {got}",
1001        );
1002    }
1003
1004    #[test]
1005    fn eval_inf_minus_inf_is_nan_not_a_panic() {
1006        // An infinite basis makes both percentages infinite; inf - inf == NaN.
1007        let items = [
1008            val(SizeMetric::Percent, 100.0),
1009            CalcAstItem::Sub,
1010            val(SizeMetric::Percent, 100.0),
1011        ];
1012        assert!(eval(&items, f32::INFINITY).is_nan());
1013    }
1014
1015    #[test]
1016    fn eval_nan_basis_does_not_leak_into_an_absolute_expression() {
1017        let items = [px(10.0), CalcAstItem::Add, px(5.0)];
1018        assert_eq!(eval(&items, f32::NAN), 15.0);
1019        assert_eq!(eval(&items, f32::INFINITY), 15.0);
1020    }
1021
1022    #[test]
1023    fn eval_negative_results_are_not_clamped() {
1024        // calc(20px - 100%) with a 500px basis is legitimately negative here;
1025        // clamping is the caller's job, not the evaluator's.
1026        let items = [px(20.0), CalcAstItem::Sub, val(SizeMetric::Percent, 100.0)];
1027        assert_eq!(eval(&items, 500.0), -480.0);
1028    }
1029
1030    #[test]
1031    fn eval_viewport_units_inside_calc_use_the_no_viewport_fallback() {
1032        // evaluate_calc_ast routes through `resolve_pixel_value`, which has no
1033        // viewport context — so `50vw` inside calc() degrades to `50px`.
1034        let items = [val(SizeMetric::Vw, 50.0), CalcAstItem::Add, px(0.0)];
1035        assert_eq!(eval(&items, 1000.0), 50.0);
1036    }
1037
1038    // ==================================================================
1039    // evaluate_calc (public wrapper)
1040    // ==================================================================
1041
1042    #[test]
1043    fn evaluate_calc_empty_context_is_zero() {
1044        let c = ctx(Vec::new(), 16.0, 10.0);
1045        assert_eq!(evaluate_calc(&c, 500.0), 0.0);
1046        assert_eq!(evaluate_calc(&c, f32::NAN), 0.0);
1047    }
1048
1049    #[test]
1050    fn evaluate_calc_matches_the_ast_evaluator_it_wraps() {
1051        let items = vec![
1052            val(SizeMetric::Percent, 100.0),
1053            CalcAstItem::Sub,
1054            CalcAstItem::BraceOpen,
1055            val(SizeMetric::Em, 2.0),
1056            CalcAstItem::Add,
1057            val(SizeMetric::Rem, 1.0),
1058            CalcAstItem::BraceClose,
1059        ];
1060        let c = ctx(items.clone(), 12.0, 20.0);
1061        // 100% of 500 - (2*12 + 1*20) == 500 - 44 == 456
1062        assert_eq!(evaluate_calc(&c, 500.0), 456.0);
1063        assert_eq!(evaluate_calc(&c, 500.0), evaluate_calc_ast(&items, 500.0, 12.0, 20.0));
1064    }
1065
1066    #[test]
1067    fn evaluate_calc_forwards_its_own_em_and_rem_sizes_without_swapping_them() {
1068        // `2em + 1rem` is asymmetric on purpose: swapping em/rem must change the
1069        // answer, which a transposed-argument bug in the wrapper would not.
1070        let items = vec![val(SizeMetric::Em, 2.0), CalcAstItem::Add, val(SizeMetric::Rem, 1.0)];
1071        assert_eq!(evaluate_calc(&ctx(items.clone(), 10.0, 20.0), 0.0), 40.0);
1072        assert_eq!(evaluate_calc(&ctx(items, 20.0, 10.0), 0.0), 50.0);
1073    }
1074
1075    #[test]
1076    fn evaluate_calc_with_hostile_font_sizes_does_not_panic() {
1077        let items = vec![val(SizeMetric::Em, 2.0), CalcAstItem::Add, px(1.0)];
1078        assert!(evaluate_calc(&ctx(items.clone(), f32::NAN, 10.0), 0.0).is_nan());
1079
1080        let inf = evaluate_calc(&ctx(items.clone(), f32::INFINITY, 10.0), 0.0);
1081        assert!(inf.is_infinite() && inf.is_sign_positive());
1082
1083        // A zero font-size is legal CSS and must give a plain 1px, not NaN.
1084        assert_eq!(evaluate_calc(&ctx(items, 0.0, 10.0), 0.0), 1.0);
1085    }
1086
1087    #[test]
1088    fn evaluate_calc_is_pure_and_repeatable() {
1089        // The context is borrowed, not consumed: evaluating twice with different
1090        // bases must not mutate or corrupt the AST.
1091        let c = ctx(
1092            vec![val(SizeMetric::Percent, 50.0), CalcAstItem::Add, px(10.0)],
1093            16.0,
1094            10.0,
1095        );
1096        assert_eq!(evaluate_calc(&c, 100.0), 60.0);
1097        assert_eq!(evaluate_calc(&c, 200.0), 110.0);
1098        assert_eq!(evaluate_calc(&c, 100.0), 60.0);
1099    }
1100}
1101