Skip to main content

omena_value_lattice/
number.rs

1use crate::{
2    NumericValueV0, parse_whole_function_value_arguments, parse_whole_function_value_inner,
3};
4
5pub fn compress_numeric_token_text(text: &str) -> Option<String> {
6    let split = numeric_prefix_end(text)?;
7    let (number, suffix) = text.split_at(split);
8    let compressed = compress_number_prefix(number);
9    let rewritten = format!("{compressed}{suffix}");
10    (rewritten != text).then_some(rewritten)
11}
12
13pub fn parse_reducible_calc_value(value: &str) -> Option<String> {
14    let inner = parse_whole_function_value_inner(value, "calc")?;
15    let reduced = parse_reducible_numeric_expression(inner)?;
16    Some(format_numeric_value_with_unit(reduced))
17}
18
19/// Reduces a standalone static numeric CSS expression into its shortest value text.
20pub fn reduce_static_numeric_expression(value: &str) -> Option<String> {
21    let reduced = parse_reducible_numeric_expression(value)?;
22    Some(format_numeric_value_with_unit(reduced))
23}
24
25pub fn parse_reducible_abs_value(value: &str) -> Option<String> {
26    let inner = parse_whole_function_value_inner(value, "abs")?;
27    let parsed = parse_reducible_numeric_expression(inner)?;
28    Some(format_numeric_value_with_unit(NumericValueV0 {
29        value: parsed.value.abs(),
30        unit: parsed.unit,
31    }))
32}
33
34pub fn parse_reducible_sign_value(value: &str) -> Option<String> {
35    let inner = parse_whole_function_value_inner(value, "sign")?;
36    let parsed = parse_reducible_numeric_expression(inner)?;
37    let value = if parsed.value > 0.0 {
38        1.0
39    } else if parsed.value < 0.0 {
40        -1.0
41    } else {
42        0.0
43    };
44    Some(format_css_number(value))
45}
46
47pub fn parse_reducible_ceil_value(value: &str) -> Option<String> {
48    parse_reducible_unary_numeric_transform_value(value, "ceil", f64::ceil)
49}
50
51pub fn parse_reducible_floor_value(value: &str) -> Option<String> {
52    parse_reducible_unary_numeric_transform_value(value, "floor", f64::floor)
53}
54
55pub fn parse_reducible_round_to_integer_value(value: &str) -> Option<String> {
56    parse_reducible_unary_numeric_transform_value(value, "round", f64::round)
57}
58
59pub fn parse_reducible_round_value(value: &str) -> Option<String> {
60    let arguments = parse_whole_function_value_arguments(value, "round")?;
61    let (strategy, value, interval) = match arguments.as_slice() {
62        [value, interval] => (
63            StaticRoundStrategy::Nearest,
64            value.as_str(),
65            interval.as_str(),
66        ),
67        [strategy, value, interval] => (
68            StaticRoundStrategy::parse(strategy.trim())?,
69            value.as_str(),
70            interval.as_str(),
71        ),
72        _ => return None,
73    };
74    let value = parse_reducible_numeric_expression(value.trim())?;
75    let interval = parse_reducible_numeric_expression(interval.trim())?;
76    if value.unit != interval.unit || interval.value <= 0.0 {
77        return None;
78    }
79    let quotient = value.value / interval.value;
80    let rounded = strategy.apply(quotient)?;
81    Some(format_numeric_value_with_unit(NumericValueV0 {
82        value: rounded * interval.value,
83        unit: value.unit,
84    }))
85}
86
87pub fn parse_reducible_mod_value(value: &str) -> Option<String> {
88    parse_reducible_positive_remainder_value(value, "mod")
89}
90
91pub fn parse_reducible_rem_value(value: &str) -> Option<String> {
92    parse_reducible_positive_remainder_value(value, "rem")
93}
94
95pub fn parse_reducible_hypot_value(value: &str) -> Option<String> {
96    let arguments = parse_whole_function_value_arguments(value, "hypot")?;
97    let first_argument = arguments.first()?;
98    let first = parse_reducible_numeric_expression(first_argument.trim())?;
99    let mut sum_of_squares = first.value * first.value;
100
101    for argument in arguments.iter().skip(1) {
102        let parsed = parse_reducible_numeric_expression(argument.trim())?;
103        if parsed.unit != first.unit {
104            return None;
105        }
106        sum_of_squares += parsed.value * parsed.value;
107    }
108
109    Some(format_numeric_value_with_unit(NumericValueV0 {
110        value: sum_of_squares.sqrt(),
111        unit: first.unit,
112    }))
113}
114
115pub fn parse_reducible_sqrt_value(value: &str) -> Option<String> {
116    let inner = parse_whole_function_value_inner(value, "sqrt")?;
117    let parsed = parse_reducible_numeric_expression(inner)?;
118    if !parsed.unit.is_empty() || parsed.value < 0.0 {
119        return None;
120    }
121    Some(format_css_number(parsed.value.sqrt()))
122}
123
124pub fn parse_reducible_pow_value(value: &str) -> Option<String> {
125    let arguments = parse_whole_function_value_arguments(value, "pow")?;
126    let [base, exponent] = arguments.as_slice() else {
127        return None;
128    };
129    let base = parse_reducible_numeric_expression(base.trim())?;
130    let exponent = parse_reducible_numeric_expression(exponent.trim())?;
131    if !base.unit.is_empty() || !exponent.unit.is_empty() {
132        return None;
133    }
134    let value = base.value.powf(exponent.value);
135    value.is_finite().then(|| format_css_number(value))
136}
137
138pub fn parse_reducible_exp_value(value: &str) -> Option<String> {
139    let inner = parse_whole_function_value_inner(value, "exp")?;
140    let parsed = parse_reducible_numeric_expression(inner)?;
141    if !parsed.unit.is_empty() {
142        return None;
143    }
144    let value = parsed.value.exp();
145    value.is_finite().then(|| format_css_number(value))
146}
147
148pub fn parse_reducible_log_value(value: &str) -> Option<String> {
149    let arguments = parse_whole_function_value_arguments(value, "log")?;
150    let value = match arguments.as_slice() {
151        [value] | [value, _] => value,
152        _ => return None,
153    };
154    let value = parse_reducible_numeric_expression(value.trim())?;
155    if !value.unit.is_empty() || value.value <= 0.0 {
156        return None;
157    };
158    let base = match arguments.as_slice() {
159        [_] => std::f64::consts::E,
160        [_, base] => {
161            let base = parse_reducible_numeric_expression(base.trim())?;
162            if !base.unit.is_empty() || base.value <= 0.0 || base.value == 1.0 {
163                return None;
164            }
165            base.value
166        }
167        _ => return None,
168    };
169    let result = value.value.log(base);
170    result.is_finite().then(|| format_css_number(result))
171}
172
173pub fn parse_reducible_min_value(value: &str) -> Option<String> {
174    parse_reducible_extreme_value(value, "min", f64::min)
175}
176
177pub fn parse_reducible_max_value(value: &str) -> Option<String> {
178    parse_reducible_extreme_value(value, "max", f64::max)
179}
180
181pub fn parse_reducible_clamp_value(value: &str) -> Option<String> {
182    let arguments = parse_whole_function_value_arguments(value, "clamp")?;
183    let [minimum, preferred, maximum] = arguments.as_slice() else {
184        return None;
185    };
186    let minimum = parse_numeric_value_with_unit(minimum.trim())?;
187    let preferred = parse_numeric_value_with_unit(preferred.trim())?;
188    let maximum = parse_numeric_value_with_unit(maximum.trim())?;
189    if preferred.unit != minimum.unit || maximum.unit != minimum.unit {
190        return None;
191    }
192    let selected = preferred.value.min(maximum.value).max(minimum.value);
193    Some(format!("{}{}", format_css_number(selected), minimum.unit))
194}
195
196fn parse_reducible_extreme_value(
197    value: &str,
198    function_name: &str,
199    reduce: fn(f64, f64) -> f64,
200) -> Option<String> {
201    let arguments = parse_whole_function_value_arguments(value, function_name)?;
202    let first = arguments.first()?;
203    let first = parse_numeric_value_with_unit(first.trim())?;
204    let mut selected = first.value;
205    let unit = first.unit;
206
207    for argument in arguments.iter().skip(1) {
208        let candidate = parse_numeric_value_with_unit(argument.trim())?;
209        if candidate.unit != unit {
210            return None;
211        }
212        selected = reduce(selected, candidate.value);
213    }
214
215    Some(format!("{}{}", format_css_number(selected), unit))
216}
217
218fn parse_reducible_positive_remainder_value(value: &str, function_name: &str) -> Option<String> {
219    let arguments = parse_whole_function_value_arguments(value, function_name)?;
220    let [dividend, divisor] = arguments.as_slice() else {
221        return None;
222    };
223    let dividend = parse_reducible_numeric_expression(dividend.trim())?;
224    let divisor = parse_reducible_numeric_expression(divisor.trim())?;
225    if dividend.unit != divisor.unit || dividend.value < 0.0 || divisor.value <= 0.0 {
226        return None;
227    }
228    Some(format_numeric_value_with_unit(NumericValueV0 {
229        value: dividend.value % divisor.value,
230        unit: dividend.unit,
231    }))
232}
233
234fn parse_reducible_unary_numeric_transform_value(
235    value: &str,
236    function_name: &str,
237    transform: fn(f64) -> f64,
238) -> Option<String> {
239    let inner = parse_whole_function_value_inner(value, function_name)?;
240    let parsed = parse_reducible_numeric_expression(inner)?;
241    let value = transform(parsed.value);
242    value.is_finite().then(|| {
243        format_numeric_value_with_unit(NumericValueV0 {
244            value,
245            unit: parsed.unit,
246        })
247    })
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251enum StaticRoundStrategy {
252    Nearest,
253    Up,
254    Down,
255    ToZero,
256}
257
258impl StaticRoundStrategy {
259    fn parse(text: &str) -> Option<Self> {
260        match text.to_ascii_lowercase().as_str() {
261            "nearest" => Some(Self::Nearest),
262            "up" => Some(Self::Up),
263            "down" => Some(Self::Down),
264            "to-zero" => Some(Self::ToZero),
265            _ => None,
266        }
267    }
268
269    fn apply(self, value: f64) -> Option<f64> {
270        match self {
271            Self::Nearest if quotient_is_halfway_between_integers(value) => None,
272            Self::Nearest => Some(value.round()),
273            Self::Up => Some(value.ceil()),
274            Self::Down => Some(value.floor()),
275            Self::ToZero => Some(value.trunc()),
276        }
277    }
278}
279
280fn quotient_is_halfway_between_integers(value: f64) -> bool {
281    (value.abs().fract() - 0.5).abs() < f64::EPSILON
282}
283
284pub fn parse_numeric_value_with_unit(text: &str) -> Option<NumericValueV0<'_>> {
285    let text = text.trim();
286    let mut parser = NumericExpressionParser::new(text);
287    let parsed = parser.parse_number()?;
288    parser.skip_whitespace();
289    (parser.is_eof()).then_some(parsed)
290}
291
292fn parse_reducible_numeric_expression(inner: &str) -> Option<NumericValueV0<'_>> {
293    let mut parser = NumericExpressionParser::new(inner);
294    let parsed = parser.parse_expression()?;
295    parser.skip_whitespace();
296    parser.is_eof().then_some(parsed)
297}
298
299struct NumericExpressionParser<'a> {
300    text: &'a str,
301    index: usize,
302}
303
304impl<'a> NumericExpressionParser<'a> {
305    fn new(text: &'a str) -> Self {
306        Self { text, index: 0 }
307    }
308
309    fn parse_expression(&mut self) -> Option<NumericValueV0<'a>> {
310        let mut left = self.parse_term()?;
311        loop {
312            self.skip_whitespace();
313            let Some(operator) = self.peek_char().filter(|ch| matches!(ch, '+' | '-')) else {
314                break;
315            };
316            self.index += operator.len_utf8();
317            let right = self.parse_term()?;
318            left = combine_numeric_additive(left, right, operator)?;
319        }
320        Some(left)
321    }
322
323    fn parse_term(&mut self) -> Option<NumericValueV0<'a>> {
324        let mut left = self.parse_factor()?;
325        loop {
326            self.skip_whitespace();
327            let Some(operator) = self.peek_char().filter(|ch| matches!(ch, '*' | '/')) else {
328                break;
329            };
330            self.index += operator.len_utf8();
331            let right = self.parse_factor()?;
332            left = combine_numeric_multiplicative(left, right, operator)?;
333        }
334        Some(left)
335    }
336
337    fn parse_factor(&mut self) -> Option<NumericValueV0<'a>> {
338        self.skip_whitespace();
339        if self.consume_char('(') {
340            let parsed = self.parse_expression()?;
341            self.skip_whitespace();
342            self.consume_char(')').then_some(parsed)
343        } else {
344            self.parse_number()
345        }
346    }
347
348    fn parse_number(&mut self) -> Option<NumericValueV0<'a>> {
349        self.skip_whitespace();
350        let start = self.index;
351        let split = numeric_prefix_end(&self.text[start..])?;
352        let number_end = start + split;
353        let unit_start = number_end;
354        self.index = number_end;
355        if self.peek_char() == Some('%') {
356            self.index += '%'.len_utf8();
357        } else {
358            while self.peek_char().is_some_and(is_css_numeric_unit_continue) {
359                let ch = self.peek_char()?;
360                self.index += ch.len_utf8();
361            }
362        }
363        let number = &self.text[start..number_end];
364        let unit = &self.text[unit_start..self.index];
365        let value = number.parse::<f64>().ok()?;
366        value.is_finite().then_some(NumericValueV0 { value, unit })
367    }
368
369    fn skip_whitespace(&mut self) {
370        while let Some(ch) = self.peek_char() {
371            if !ch.is_whitespace() {
372                break;
373            }
374            self.index += ch.len_utf8();
375        }
376    }
377
378    fn consume_char(&mut self, expected: char) -> bool {
379        if self.peek_char() == Some(expected) {
380            self.index += expected.len_utf8();
381            true
382        } else {
383            false
384        }
385    }
386
387    fn peek_char(&self) -> Option<char> {
388        self.text[self.index..].chars().next()
389    }
390
391    fn is_eof(&self) -> bool {
392        self.index == self.text.len()
393    }
394}
395
396fn combine_numeric_additive<'a>(
397    left: NumericValueV0<'a>,
398    right: NumericValueV0<'a>,
399    operator: char,
400) -> Option<NumericValueV0<'a>> {
401    if left.unit != right.unit {
402        return None;
403    }
404    let value = if operator == '+' {
405        left.value + right.value
406    } else {
407        left.value - right.value
408    };
409    Some(NumericValueV0 {
410        value,
411        unit: left.unit,
412    })
413}
414
415fn combine_numeric_multiplicative<'a>(
416    left: NumericValueV0<'a>,
417    right: NumericValueV0<'a>,
418    operator: char,
419) -> Option<NumericValueV0<'a>> {
420    match operator {
421        '*' if left.unit.is_empty() && right.unit.is_empty() => Some(NumericValueV0 {
422            value: left.value * right.value,
423            unit: "",
424        }),
425        '*' if left.unit.is_empty() => Some(NumericValueV0 {
426            value: left.value * right.value,
427            unit: right.unit,
428        }),
429        '*' if right.unit.is_empty() => Some(NumericValueV0 {
430            value: left.value * right.value,
431            unit: left.unit,
432        }),
433        '/' if right.unit.is_empty() && right.value != 0.0 => Some(NumericValueV0 {
434            value: left.value / right.value,
435            unit: left.unit,
436        }),
437        _ => None,
438    }
439}
440
441fn format_numeric_value_with_unit(value: NumericValueV0<'_>) -> String {
442    format!("{}{}", format_css_number(value.value), value.unit)
443}
444
445fn is_css_numeric_unit_continue(ch: char) -> bool {
446    ch.is_ascii_alphabetic()
447}
448
449pub fn format_css_number(value: f64) -> String {
450    if value.fract() == 0.0 {
451        return format!("{value:.0}");
452    }
453    let formatted = format!("{value:.6}");
454    formatted
455        .trim_end_matches('0')
456        .trim_end_matches('.')
457        .to_string()
458}
459
460pub fn numeric_prefix_end(text: &str) -> Option<usize> {
461    let bytes = text.as_bytes();
462    let mut index = 0;
463
464    if matches!(bytes.get(index), Some(b'+') | Some(b'-')) {
465        index += 1;
466    }
467
468    let integer_start = index;
469    while matches!(bytes.get(index), Some(b'0'..=b'9')) {
470        index += 1;
471    }
472    let saw_integer_digit = index > integer_start;
473
474    if bytes.get(index) == Some(&b'.') {
475        index += 1;
476        let fraction_start = index;
477        while matches!(bytes.get(index), Some(b'0'..=b'9')) {
478            index += 1;
479        }
480        if !saw_integer_digit && index == fraction_start {
481            return None;
482        }
483    } else if !saw_integer_digit {
484        return None;
485    }
486
487    if matches!(bytes.get(index), Some(b'e') | Some(b'E')) {
488        let exponent_marker = index;
489        let mut exponent_index = index + 1;
490        if matches!(bytes.get(exponent_index), Some(b'+') | Some(b'-')) {
491            exponent_index += 1;
492        }
493        let exponent_digit_start = exponent_index;
494        while matches!(bytes.get(exponent_index), Some(b'0'..=b'9')) {
495            exponent_index += 1;
496        }
497        if exponent_index > exponent_digit_start {
498            index = exponent_index;
499        } else {
500            index = exponent_marker;
501        }
502    }
503
504    Some(index)
505}
506
507pub fn compress_number_prefix(number: &str) -> String {
508    let (sign, unsigned) = match number.as_bytes().first() {
509        Some(b'+') | Some(b'-') => (&number[..1], &number[1..]),
510        _ => ("", number),
511    };
512    let sign = if sign == "+" || is_zero_number_prefix(unsigned) {
513        ""
514    } else {
515        sign
516    };
517    let (mantissa, exponent) = split_number_exponent(unsigned);
518    let compressed_mantissa = compress_decimal_mantissa(mantissa);
519    let mut compressed = format!("{sign}{compressed_mantissa}");
520
521    if let Some(exponent) = exponent {
522        let normalized_exponent = normalize_exponent_suffix(exponent);
523        if normalized_exponent != "0" && !is_zero_number_prefix(&compressed) {
524            compressed.push('e');
525            compressed.push_str(&normalized_exponent);
526        }
527    }
528
529    compressed
530}
531
532fn split_number_exponent(number: &str) -> (&str, Option<&str>) {
533    if let Some(index) = number.find(['e', 'E']) {
534        (&number[..index], Some(&number[index + 1..]))
535    } else {
536        (number, None)
537    }
538}
539
540fn compress_decimal_mantissa(mantissa: &str) -> String {
541    let Some((before_dot, after_dot)) = mantissa.split_once('.') else {
542        return compress_integer_digits(mantissa);
543    };
544
545    let trimmed_fraction = after_dot.trim_end_matches('0');
546    let compressed_integer = compress_integer_digits(before_dot);
547    let mut compressed_unsigned = if trimmed_fraction.is_empty() {
548        compressed_integer
549    } else {
550        format!("{compressed_integer}.{trimmed_fraction}")
551    };
552
553    if let Some(rest) = compressed_unsigned.strip_prefix("0.") {
554        compressed_unsigned = format!(".{rest}");
555    }
556
557    if compressed_unsigned.is_empty() {
558        compressed_unsigned.push('0');
559    }
560
561    compressed_unsigned
562}
563
564fn compress_integer_digits(digits: &str) -> String {
565    let trimmed = digits.trim_start_matches('0');
566    if trimmed.is_empty() {
567        "0".to_string()
568    } else {
569        trimmed.to_string()
570    }
571}
572
573fn normalize_exponent_suffix(exponent: &str) -> String {
574    let (sign, digits) = match exponent.as_bytes().first() {
575        Some(b'+') => ("", &exponent[1..]),
576        Some(b'-') => ("-", &exponent[1..]),
577        _ => ("", exponent),
578    };
579    let digits = digits.trim_start_matches('0');
580    let digits = if digits.is_empty() { "0" } else { digits };
581    if digits == "0" {
582        digits.to_string()
583    } else {
584        format!("{sign}{digits}")
585    }
586}
587
588pub fn css_number_is_zero(number: &str) -> bool {
589    parse_numeric_value_with_unit(number)
590        .is_some_and(|value| value.unit.is_empty() && value.value == 0.0)
591}
592
593fn is_zero_number_prefix(number: &str) -> bool {
594    css_number_is_zero(number)
595}