omena-transform-passes 0.2.0

Transform pass registry and DAG planner for Omena CSS
Documentation
use omena_parser::LexedToken;

use crate::{
    domains::number::numeric_prefix_end,
    helpers::{
        ascii::normalize_ascii_whitespace,
        declarations::{SimpleDeclarationSlice, declaration_ranges_are_adjacent},
        values::{split_top_level_value_arguments, split_top_level_whitespace_value_components},
    },
};

pub(crate) fn transition_shorthand_replacement_for_declarations(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Option<(usize, usize, String)> {
    let [property, duration, timing, delay] = declarations else {
        return None;
    };
    if property.property != "transition-property"
        || duration.property != "transition-duration"
        || timing.property != "transition-timing-function"
        || delay.property != "transition-delay"
        || !declaration_ranges_are_adjacent(tokens, declarations)
    {
        return None;
    }

    let important = property.important;
    let components = declarations
        .iter()
        .map(|declaration| {
            if declaration.important != important {
                return None;
            }
            single_motion_longhand_value_without_important(declaration)
        })
        .collect::<Option<Vec<_>>>()?;
    let [property, duration, timing, delay] = components.as_slice() else {
        return None;
    };
    let shorthand = compress_single_transition_value(
        format!("{property} {duration} {timing} {delay}").as_str(),
    )?;
    let important = if important { "!important" } else { "" };
    Some((
        declarations.first()?.start,
        declarations.last()?.end,
        format!("transition: {shorthand}{important};"),
    ))
}

pub(crate) fn animation_shorthand_replacement_for_declarations(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Option<(usize, usize, String)> {
    let [
        name,
        duration,
        timing,
        delay,
        iteration_count,
        direction,
        fill_mode,
        play_state,
    ] = declarations
    else {
        return None;
    };
    if name.property != "animation-name"
        || duration.property != "animation-duration"
        || timing.property != "animation-timing-function"
        || delay.property != "animation-delay"
        || iteration_count.property != "animation-iteration-count"
        || direction.property != "animation-direction"
        || fill_mode.property != "animation-fill-mode"
        || play_state.property != "animation-play-state"
        || !declaration_ranges_are_adjacent(tokens, declarations)
    {
        return None;
    }

    let important = name.important;
    let components = declarations
        .iter()
        .map(|declaration| {
            if declaration.important != important {
                return None;
            }
            single_motion_longhand_value_without_important(declaration)
        })
        .collect::<Option<Vec<_>>>()?;
    let [
        name,
        duration,
        timing,
        delay,
        iteration_count,
        direction,
        fill_mode,
        play_state,
    ] = components.as_slice()
    else {
        return None;
    };
    let shorthand_seed = format!(
        "{name} {duration} {timing} {delay} {iteration_count} {direction} {fill_mode} {play_state}"
    );
    let shorthand = compress_single_animation_value(&shorthand_seed).unwrap_or(shorthand_seed);
    let important = if important { "!important" } else { "" };

    Some((
        declarations.first()?.start,
        declarations.last()?.end,
        format!("animation: {shorthand}{important};"),
    ))
}

pub(crate) fn compress_transition_value(value: &str) -> Option<String> {
    let transitions = split_top_level_value_arguments(value)?;
    let mut compressed = Vec::with_capacity(transitions.len());
    let mut changed = false;

    for transition in transitions {
        let replacement = compress_single_transition_value(&transition)?;
        changed |= replacement != normalize_ascii_whitespace(&transition);
        compressed.push(replacement);
    }

    changed.then(|| compressed.join(","))
}

fn compress_single_transition_value(value: &str) -> Option<String> {
    let components = split_top_level_whitespace_value_components(value)?;
    let [property, duration, timing, delay] = components.as_slice() else {
        return None;
    };
    if !is_css_time_value(duration) || !is_css_time_value(delay) {
        return None;
    }

    let duration_is_zero = is_zero_css_time_value(duration);
    let delay_is_zero = is_zero_css_time_value(delay);
    let timing_is_default = timing.eq_ignore_ascii_case("ease");

    let mut output = vec![normalize_transition_property(property)];
    if !duration_is_zero || !delay_is_zero {
        output.push(duration.clone());
    }
    if !timing_is_default {
        output.push(timing.clone());
    }
    if !delay_is_zero {
        output.push(delay.clone());
    }

    Some(output.join(" "))
}

fn normalize_transition_property(property: &str) -> String {
    if property.eq_ignore_ascii_case("all") {
        "all".to_string()
    } else {
        property.to_string()
    }
}

fn single_motion_longhand_value_without_important(
    declaration: &SimpleDeclarationSlice,
) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(&declaration.value)?;
    if declaration.important
        && components.last().is_some_and(|component| {
            component.eq_ignore_ascii_case("!important")
                || component.eq_ignore_ascii_case("important")
        })
    {
        components.pop();
    }
    let [component] = components.as_slice() else {
        return None;
    };
    Some(component.clone())
}

pub(crate) fn compress_animation_value(value: &str) -> Option<String> {
    let animations = split_top_level_value_arguments(value)?;
    let mut compressed = Vec::with_capacity(animations.len());
    let mut changed = false;

    for animation in animations {
        let replacement = compress_single_animation_value(&animation)?;
        changed |= replacement != normalize_ascii_whitespace(&animation);
        compressed.push(replacement);
    }

    changed.then(|| compressed.join(","))
}

fn compress_single_animation_value(value: &str) -> Option<String> {
    let components = split_top_level_whitespace_value_components(value)?;
    let [first, second, third, fourth, fifth, sixth, seventh, eighth] = components.as_slice()
    else {
        return None;
    };

    if animation_tail_is_default(second, third, fourth, fifth, sixth, seventh, eighth) {
        return Some(first.clone());
    }
    if animation_head_is_default(first, second, third, fourth, fifth, sixth, seventh) {
        return Some(eighth.clone());
    }

    None
}

fn animation_tail_is_default(
    duration: &str,
    timing: &str,
    delay: &str,
    iteration_count: &str,
    direction: &str,
    fill_mode: &str,
    play_state: &str,
) -> bool {
    is_zero_css_time_value(duration)
        && timing.eq_ignore_ascii_case("ease")
        && is_zero_css_time_value(delay)
        && iteration_count == "1"
        && direction.eq_ignore_ascii_case("normal")
        && fill_mode.eq_ignore_ascii_case("none")
        && play_state.eq_ignore_ascii_case("running")
}

fn animation_head_is_default(
    duration: &str,
    timing: &str,
    delay: &str,
    iteration_count: &str,
    direction: &str,
    fill_mode: &str,
    play_state: &str,
) -> bool {
    animation_tail_is_default(
        duration,
        timing,
        delay,
        iteration_count,
        direction,
        fill_mode,
        play_state,
    )
}

fn is_css_time_value(value: &str) -> bool {
    css_time_value_parts(value).is_some()
}

fn is_zero_css_time_value(value: &str) -> bool {
    css_time_value_parts(value).is_some_and(|(number, _)| {
        number
            .parse::<f64>()
            .is_ok_and(|parsed| parsed.is_finite() && parsed == 0.0)
    })
}

fn css_time_value_parts(value: &str) -> Option<(&str, &str)> {
    let split = numeric_prefix_end(value)?;
    if split == value.len() {
        return None;
    }
    let (number, unit) = value.split_at(split);
    matches!(unit.to_ascii_lowercase().as_str(), "s" | "ms").then_some((number, unit))
}