omena-transform-passes 0.2.0

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

use crate::helpers::{
    ascii::normalize_ascii_whitespace,
    declarations::{SimpleDeclarationSlice, declaration_ranges_are_adjacent},
    values::split_top_level_whitespace_value_components,
};

pub(crate) fn list_style_shorthand_replacement_for_declarations(
    tokens: &[LexedToken],
    declarations: &[SimpleDeclarationSlice],
) -> Option<(usize, usize, String)> {
    let [style_type, position, image] = declarations else {
        return None;
    };
    if style_type.property != "list-style-type"
        || position.property != "list-style-position"
        || image.property != "list-style-image"
        || declarations.iter().any(|declaration| declaration.important)
        || !declaration_ranges_are_adjacent(tokens, declarations)
    {
        return None;
    }
    let shorthand_value =
        compressed_list_style_components(&style_type.value, &position.value, &image.value)?;
    Some((
        style_type.start,
        image.end,
        format!("list-style: {shorthand_value};"),
    ))
}

pub(crate) fn compress_list_style_value(value: &str) -> Option<String> {
    let components = split_top_level_whitespace_value_components(value)?;
    let mut style_type = None;
    let mut position = "outside".to_string();
    let mut image = None;

    for component in &components {
        let lower_component = component.to_ascii_lowercase();
        if is_list_style_position(&lower_component) {
            position = lower_component;
        } else if lower_component == "none" {
            if style_type.is_none() {
                style_type = Some(lower_component);
            } else if image.is_none() {
                image = Some(lower_component);
            } else {
                return None;
            }
        } else if is_list_style_image(component) && image.is_none() {
            image = Some(component.clone());
        } else if is_list_style_type(&lower_component) && style_type.is_none() {
            style_type = Some(lower_component);
        } else {
            return None;
        }
    }

    let style_type = style_type.unwrap_or_else(|| "disc".to_string());
    let image = image.unwrap_or_else(|| "none".to_string());
    let compressed = compressed_list_style_components(&style_type, &position, &image)?;
    (compressed != normalize_ascii_whitespace(value)).then_some(compressed)
}

fn compressed_list_style_components(
    style_type: &str,
    position: &str,
    image: &str,
) -> Option<String> {
    let style_type = normalize_list_style_type(style_type)?;
    let position = normalize_list_style_position(position)?;
    let image = normalize_list_style_image(image)?;

    if style_type == "none" && image == "none" {
        return Some(if position == "outside" {
            "none".to_string()
        } else {
            format!("{position} none")
        });
    }
    if style_type == "none" {
        return Some(if position == "outside" {
            format!("{image} none")
        } else {
            format!("{position} {image} none")
        });
    }

    let mut components = Vec::new();
    if position != "outside" || (style_type == "disc" && image == "none") {
        components.push(position);
    }
    if style_type != "disc" && !(style_type == "none" && image == "none") {
        components.push(style_type);
    }
    if image != "none" {
        components.push(image);
    }
    if components.is_empty() {
        components.push("outside".to_string());
    }
    Some(components.join(" "))
}

fn is_list_style_position(value: &str) -> bool {
    matches!(value, "inside" | "outside")
}

fn normalize_list_style_position(value: &str) -> Option<String> {
    let lower = value.to_ascii_lowercase();
    is_list_style_position(&lower).then_some(lower)
}

fn is_list_style_type(value: &str) -> bool {
    matches!(
        value,
        "disc"
            | "circle"
            | "square"
            | "decimal"
            | "decimal-leading-zero"
            | "lower-roman"
            | "upper-roman"
            | "lower-alpha"
            | "upper-alpha"
            | "none"
    )
}

fn normalize_list_style_type(value: &str) -> Option<String> {
    let lower = value.to_ascii_lowercase();
    is_list_style_type(&lower).then_some(lower)
}

fn is_list_style_image(value: &str) -> bool {
    value == "none"
        || value
            .get(.."url(".len())
            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("url("))
}

fn normalize_list_style_image(value: &str) -> Option<String> {
    if value.eq_ignore_ascii_case("none") {
        return Some("none".to_string());
    }
    is_list_style_image(value).then(|| value.to_string())
}