omena-transform-passes 0.1.14

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_whitespace_value_components,
    },
};

pub(crate) fn text_decoration_shorthand_replacement_for_declarations(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Option<(usize, usize, String)> {
    let [line, style, color, thickness] = declarations else {
        return None;
    };
    if line.property != "text-decoration-line"
        || style.property != "text-decoration-style"
        || color.property != "text-decoration-color"
        || thickness.property != "text-decoration-thickness"
        || declarations
            .iter()
            .any(|declaration| declaration.important != line.important)
        || !declaration_ranges_are_adjacent(tokens, declarations)
    {
        return None;
    }
    let line_value = text_decoration_line_without_important(&line.value, line.important)?;
    let style_value = single_component_value_without_important(&style.value, style.important)?;
    let color_value = single_component_value_without_important(&color.value, color.important)?;
    let thickness_value =
        single_component_value_without_important(&thickness.value, thickness.important)?;
    let shorthand_value = compressed_text_decoration_components(
        &line_value,
        &style_value,
        &color_value,
        &thickness_value,
    )?;
    let important = if line.important { "!important" } else { "" };
    Some((
        line.start,
        thickness.end,
        format!("text-decoration: {shorthand_value}{important};"),
    ))
}

pub(crate) fn text_emphasis_shorthand_replacement_for_declarations(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Option<(usize, usize, String)> {
    let [style, color] = declarations else {
        return None;
    };
    if style.property != "text-emphasis-style"
        || color.property != "text-emphasis-color"
        || style.important != color.important
        || !declaration_ranges_are_adjacent(tokens, declarations)
    {
        return None;
    }
    let style_value = text_emphasis_style_without_important(&style.value, style.important)?;
    let color_value = single_component_value_without_important(&color.value, color.important)?;
    let shorthand_value = compressed_text_emphasis_components(&style_value, &color_value)?;
    let important = if style.important { "!important" } else { "" };

    Some((
        style.start,
        color.end,
        format!("text-emphasis: {shorthand_value}{important};"),
    ))
}

pub(crate) fn collect_text_emphasis_replacements(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Vec<(usize, usize, String)> {
    declarations
        .windows(2)
        .filter_map(|pair| text_emphasis_shorthand_replacement_for_declarations(tokens, pair))
        .collect()
}

pub(crate) fn compress_text_decoration_value(value: &str, important: bool) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(value)?;
    if important
        && components.last().is_some_and(|component| {
            component.eq_ignore_ascii_case("!important")
                || component.eq_ignore_ascii_case("important")
        })
    {
        components.pop();
    }

    let mut line_components = Vec::new();
    let mut style = None;
    let mut color = None;
    let mut thickness = None;
    for component in &components {
        let normalized = component.to_ascii_lowercase();
        if is_text_decoration_line_component(&normalized) {
            line_components.push(normalized);
        } else if is_text_decoration_style_component(&normalized) && style.is_none() {
            style = Some(normalized);
        } else if is_text_decoration_color_component(component) && color.is_none() {
            color = Some(normalized);
        } else if is_text_decoration_thickness_component(&normalized) && thickness.is_none() {
            thickness = Some(normalized);
        } else {
            return None;
        }
    }

    let replacement = compressed_text_decoration_components(
        &canonical_text_decoration_line_value(line_components.as_slice())?,
        style.as_deref().unwrap_or("solid"),
        color.as_deref().unwrap_or("currentcolor"),
        thickness.as_deref().unwrap_or("auto"),
    )?;
    (replacement != normalize_ascii_whitespace(value)).then_some(replacement)
}

pub(crate) fn compress_text_emphasis_position_value(
    value: &str,
    important: bool,
) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(value)?;
    if important
        && components.last().is_some_and(|component| {
            component.eq_ignore_ascii_case("!important")
                || component.eq_ignore_ascii_case("important")
        })
    {
        components.pop();
    }

    let replacement = match components.as_slice() {
        [first, second] => {
            let first = first.to_ascii_lowercase();
            let second = second.to_ascii_lowercase();
            if is_text_emphasis_over_under(&first) && is_text_emphasis_side(&second) {
                compressed_text_emphasis_position(&first, &second)?
            } else if is_text_emphasis_side(&first) && is_text_emphasis_over_under(&second) {
                compressed_text_emphasis_position(&second, &first)?
            } else {
                return None;
            }
        }
        _ => return None,
    };

    (replacement != normalize_ascii_whitespace(value)).then_some(replacement)
}

fn text_emphasis_style_without_important(value: &str, important: bool) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(value)?;
    if important
        && components.last().is_some_and(|component| {
            component.eq_ignore_ascii_case("!important")
                || component.eq_ignore_ascii_case("important")
        })
    {
        components.pop();
    }
    if components.is_empty() || components.len() > 2 {
        return None;
    }
    Some(components.join(" "))
}

fn single_component_value_without_important(value: &str, important: bool) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(value)?;
    if 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())
}

fn text_decoration_line_without_important(value: &str, important: bool) -> Option<String> {
    let mut components = split_top_level_whitespace_value_components(value)?;
    if important
        && components.last().is_some_and(|component| {
            component.eq_ignore_ascii_case("!important")
                || component.eq_ignore_ascii_case("important")
        })
    {
        components.pop();
    }
    let components = components
        .into_iter()
        .map(|component| component.to_ascii_lowercase())
        .collect::<Vec<_>>();
    canonical_text_decoration_line_value(components.as_slice())
}

fn canonical_text_decoration_line_value(components: &[String]) -> Option<String> {
    if components
        .iter()
        .any(|component| !is_text_decoration_line_component(component))
    {
        return None;
    }
    let mut values = ["underline", "overline", "line-through"]
        .iter()
        .filter(|candidate| components.iter().any(|component| component == **candidate))
        .map(|candidate| (*candidate).to_string())
        .collect::<Vec<_>>();
    if values.is_empty() && components.iter().any(|component| component == "none") {
        values.push("none".to_string());
    }
    (!values.is_empty()).then(|| values.join(" "))
}

fn compressed_text_emphasis_position(vertical: &str, side: &str) -> Option<String> {
    if side == "right" {
        Some(vertical.to_string())
    } else if side == "left" {
        Some(format!("{vertical} left"))
    } else {
        None
    }
}

fn is_text_emphasis_over_under(value: &str) -> bool {
    matches!(value, "over" | "under")
}

fn is_text_emphasis_side(value: &str) -> bool {
    matches!(value, "left" | "right")
}

fn compressed_text_emphasis_components(style: &str, color: &str) -> Option<String> {
    let style = compressed_text_emphasis_style(style)?;
    let color = color.to_ascii_lowercase();
    if !is_text_decoration_color_component(&color) {
        return None;
    }
    if color == "currentcolor" {
        Some(style)
    } else {
        Some(format!("{style} {color}"))
    }
}

fn compressed_text_emphasis_style(value: &str) -> Option<String> {
    let components = split_top_level_whitespace_value_components(value)?;
    let components = components
        .into_iter()
        .map(|component| component.to_ascii_lowercase())
        .collect::<Vec<_>>();
    match components
        .iter()
        .map(String::as_str)
        .collect::<Vec<_>>()
        .as_slice()
    {
        ["none"] => Some("none".to_string()),
        [mark] if is_text_emphasis_mark(mark) => Some(mark.to_string()),
        ["filled", mark] if is_text_emphasis_mark(mark) => Some(mark.to_string()),
        ["open", mark] if is_text_emphasis_mark(mark) => Some(format!("open {mark}")),
        _ => None,
    }
}

fn is_text_emphasis_mark(value: &str) -> bool {
    matches!(
        value,
        "dot" | "circle" | "double-circle" | "triangle" | "sesame"
    )
}

fn compressed_text_decoration_components(
    line: &str,
    style: &str,
    color: &str,
    thickness: &str,
) -> Option<String> {
    let line = line.to_ascii_lowercase();
    let style = style.to_ascii_lowercase();
    let color = color.to_ascii_lowercase();
    let thickness = thickness.to_ascii_lowercase();

    let line = canonical_text_decoration_line_value(
        split_top_level_whitespace_value_components(&line)?.as_slice(),
    )?;

    if !is_text_decoration_style_component(&style)
        || !is_text_decoration_color_component(&color)
        || !is_text_decoration_thickness_component(&thickness)
    {
        return None;
    }

    let mut components = vec![line];
    if thickness != "auto" {
        components.push(thickness);
    }
    if style != "solid" {
        components.push(style);
    }
    if color != "currentcolor" {
        components.push(color);
    }
    Some(components.join(" "))
}

fn is_text_decoration_line_component(value: &str) -> bool {
    matches!(value, "none" | "underline" | "overline" | "line-through")
}

fn is_text_decoration_style_component(value: &str) -> bool {
    matches!(value, "solid" | "double" | "dotted" | "dashed" | "wavy")
}

fn is_text_decoration_thickness_component(value: &str) -> bool {
    value == "auto"
        || value == "from-font"
        || numeric_prefix_end(value).is_some_and(|end| {
            value
                .get(end..)
                .is_some_and(is_text_decoration_thickness_unit)
        })
}

fn is_text_decoration_thickness_unit(unit: &str) -> bool {
    matches!(
        unit.to_ascii_lowercase().as_str(),
        "px" | "em" | "rem" | "ch" | "ex" | "lh" | "rlh" | "vw" | "vh" | "vmin" | "vmax" | "%"
    )
}

fn is_text_decoration_color_component(value: &str) -> bool {
    let normalized = value.to_ascii_lowercase();
    if matches!(
        normalized.as_str(),
        "inherit" | "initial" | "revert" | "revert-layer" | "unset"
    ) {
        return false;
    }
    normalized == "currentcolor"
        || normalized.starts_with('#')
        || normalized.starts_with("rgb(")
        || normalized.starts_with("rgba(")
        || normalized.starts_with("hsl(")
        || normalized.starts_with("hsla(")
        || normalized.chars().all(|character| {
            character.is_ascii_alphabetic() || character == '-' || character.is_ascii_digit()
        })
}