Skip to main content

omena_transform_passes/domains/
number.rs

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