omena-transform-passes 0.2.0

Transform pass registry and DAG planner for Omena CSS
Documentation
use omena_parser::{StyleDialect, lex};
use omena_syntax::SyntaxKind;

use crate::{
    domains::{
        number::parse_numeric_value_with_unit,
        reachability::rule_slice_matches_reachable_class_context,
    },
    helpers::{
        blocks::rule_block_token_indexes,
        collections::push_unique_string,
        declarations::collect_simple_declarations_in_block,
        identifiers::{css_identifier_escape_sequence_end, css_identifier_names_match},
        rules::collect_declaration_ordinary_rule_slices,
        source_rewrite::remove_source_ranges,
        tokens::{matching_right_brace_index, skip_whitespace_tokens, token_end, token_start},
        values::{
            split_top_level_value_arguments, split_top_level_whitespace_value_components,
            static_css_string_value,
        },
    },
    model::TransformSemanticRemovalCandidate,
};

use super::css_module_global::collect_css_module_scope_blocks;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KeyframesRuleSlice {
    pub(crate) name: String,
    pub(crate) start: usize,
    pub(crate) end: usize,
}

pub(crate) fn tree_shake_css_keyframes_with_lexer(
    source: &str,
    dialect: StyleDialect,
    reachable_keyframe_names: &[String],
    reachable_class_names: &[String],
) -> (String, Vec<TransformSemanticRemovalCandidate>) {
    let lexed = lex(source, dialect);
    let tokens = lexed.tokens();
    let keyframes = collect_keyframes_rules(tokens);
    if keyframes.is_empty() {
        return (source.to_string(), Vec::new());
    }

    let Some(mut referenced_names) =
        collect_referenced_keyframe_names(source, tokens, reachable_class_names)
    else {
        return (source.to_string(), Vec::new());
    };
    for name in reachable_keyframe_names {
        push_unique_string(&mut referenced_names, name.clone());
    }

    let removals = keyframes
        .iter()
        .filter(|keyframe| !keyframe_name_is_reachable(&keyframe.name, &referenced_names))
        .map(|keyframe| TransformSemanticRemovalCandidate {
            symbol_kind: "keyframes",
            name: keyframe.name.clone(),
            source_span_start: keyframe.start,
            source_span_end: keyframe.end,
            reason: "keyframes name was absent from animation references and the closed-style-world reachable keyframe set",
        })
        .collect::<Vec<_>>();
    let ranges = removals
        .iter()
        .map(|removal| (removal.source_span_start, removal.source_span_end))
        .collect::<Vec<_>>();
    let (output, _) = remove_source_ranges(source, &ranges);
    (output, removals)
}

pub(crate) fn collect_keyframes_rules(
    tokens: &[omena_parser::LexedToken],
) -> Vec<KeyframesRuleSlice> {
    let mut rules = Vec::new();
    let mut index = 0;

    while index < tokens.len() {
        if tokens[index].kind == SyntaxKind::AtKeyword
            && is_keyframes_at_keyword(&tokens[index].text)
            && let Some((rule, next_index)) = parse_keyframes_rule(tokens, index)
        {
            rules.push(rule);
            index = next_index;
            continue;
        }
        index += 1;
    }

    rules
}

pub(crate) fn is_keyframes_at_keyword(text: &str) -> bool {
    matches!(
        text.to_ascii_lowercase().as_str(),
        "@keyframes" | "@-webkit-keyframes"
    )
}

fn parse_keyframes_rule(
    tokens: &[omena_parser::LexedToken],
    at_keyframes_index: usize,
) -> Option<(KeyframesRuleSlice, usize)> {
    let name_index = skip_whitespace_tokens(tokens, at_keyframes_index + 1, tokens.len());
    let name_token = tokens.get(name_index)?;
    let name = static_keyframe_name_from_rule_name_token(name_token)?;
    let mut index = name_index + 1;
    while index < tokens.len() {
        match tokens[index].kind {
            SyntaxKind::Semicolon => return None,
            SyntaxKind::LeftBrace => {
                let close_index = matching_right_brace_index(tokens, index)?;
                return Some((
                    KeyframesRuleSlice {
                        name,
                        start: token_start(&tokens[at_keyframes_index]),
                        end: token_end(&tokens[close_index]),
                    },
                    close_index + 1,
                ));
            }
            _ => index += 1,
        }
    }

    None
}

fn static_keyframe_name_from_rule_name_token(token: &omena_parser::LexedToken) -> Option<String> {
    match token.kind {
        SyntaxKind::Ident => Some(token.text.clone()),
        SyntaxKind::String => static_css_string_value(&token.text),
        _ => None,
    }
}

pub(crate) fn collect_referenced_keyframe_names(
    source: &str,
    tokens: &[omena_parser::LexedToken],
    reachable_class_names: &[String],
) -> Option<Vec<String>> {
    let mut names = Vec::new();
    let scope_blocks = collect_css_module_scope_blocks(source, tokens);
    for rule in collect_declaration_ordinary_rule_slices(source, tokens) {
        if !rule_slice_matches_reachable_class_context(&rule, &scope_blocks, reachable_class_names)
        {
            continue;
        };
        let Some((block_start_index, block_end_index)) =
            rule_block_token_indexes(tokens, rule.block_start, rule.block_end)
        else {
            continue;
        };
        for declaration in
            collect_simple_declarations_in_block(tokens, block_start_index, block_end_index)
        {
            match declaration.property.as_str() {
                "animation-name" => {
                    if declaration.value.contains("var(") {
                        return None;
                    }
                    for name in split_top_level_value_arguments(&declaration.value)? {
                        if let Some(candidate) = static_animation_name_candidate(&name)
                            && (candidate.quoted || !candidate.name.eq_ignore_ascii_case("none"))
                        {
                            push_unique_string(&mut names, candidate.name);
                        }
                    }
                }
                "animation" => {
                    if declaration.value.contains("var(") {
                        return None;
                    }
                    for name in extract_animation_shorthand_name_candidates(&declaration.value)? {
                        push_unique_string(&mut names, name);
                    }
                }
                _ => {}
            }
        }
    }

    Some(names)
}

pub(crate) fn keyframe_name_is_reachable(name: &str, reachable_keyframe_names: &[String]) -> bool {
    reachable_keyframe_names
        .iter()
        .any(|reachable| css_identifier_names_match(reachable, name))
}

fn extract_animation_shorthand_name_candidates(value: &str) -> Option<Vec<String>> {
    let mut candidates = Vec::new();
    for branch in split_top_level_value_arguments(value)? {
        for part in split_top_level_whitespace_value_components(&branch)? {
            let candidate = part.trim();
            if let Some(candidate) = static_animation_name_candidate(candidate)
                && (candidate.quoted || !is_known_animation_shorthand_keyword(&candidate.name))
            {
                push_unique_string(&mut candidates, candidate.name);
            }
        }
    }
    Some(candidates)
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct StaticAnimationNameCandidate {
    name: String,
    quoted: bool,
}

fn static_animation_name_candidate(value: &str) -> Option<StaticAnimationNameCandidate> {
    let value = value.trim();
    if value.is_empty() {
        return None;
    }
    if let Some(name) = static_css_string_value(value) {
        return Some(StaticAnimationNameCandidate { name, quoted: true });
    }
    if value.contains(['(', ')', '"', '\'', '/'])
        || parse_numeric_value_with_unit(value).is_some()
        || !static_animation_custom_ident_value_is_safe(value)
    {
        return None;
    }
    Some(StaticAnimationNameCandidate {
        name: value.to_string(),
        quoted: false,
    })
}

fn static_animation_custom_ident_value_is_safe(value: &str) -> bool {
    let mut index = 0usize;
    while index < value.len() {
        let Some(ch) = value[index..].chars().next() else {
            return false;
        };
        if ch == '\\' {
            let Some(end) = css_identifier_escape_sequence_end(value, index) else {
                return false;
            };
            index = end;
            continue;
        }
        if ch.is_ascii_whitespace() || matches!(ch, ',' | ';' | '{' | '}' | '[' | ']') {
            return false;
        }
        index += ch.len_utf8();
    }
    true
}

fn is_known_animation_shorthand_keyword(value: &str) -> bool {
    matches!(
        value.to_ascii_lowercase().as_str(),
        "alternate"
            | "alternate-reverse"
            | "backwards"
            | "both"
            | "ease"
            | "ease-in"
            | "ease-in-out"
            | "ease-out"
            | "forwards"
            | "infinite"
            | "linear"
            | "none"
            | "normal"
            | "paused"
            | "reverse"
            | "running"
            | "step-end"
            | "step-start"
    )
}