omena-transform-passes 0.1.14

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

use crate::{
    domains::color::{
        is_static_color_reference_property, parse_color_function_value, parse_color_mix_value,
        parse_oklab_oklch_value,
    },
    helpers::{
        blocks::rule_block_token_indexes,
        declarations::{
            collect_simple_declarations_in_block, format_replacement_declaration_like_source,
        },
        rules::collect_declaration_ordinary_rule_slices,
        tokens::matching_right_brace_index,
        values::{
            StaticCssFunctionSpec, matching_function_call_end,
            parse_whole_function_value_arguments,
            substitute_static_css_function_references_in_value,
        },
    },
};

pub(crate) fn lower_css_light_dark_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    let lexed = lex(source, dialect);
    let tokens = lexed.tokens();
    let rules = collect_declaration_ordinary_rule_slices(source, tokens);
    let mut replacements = Vec::new();
    let mut insertions = Vec::new();

    for rule in &rules {
        let Some((block_start_index, block_end_index)) =
            rule_block_token_indexes(tokens, rule.block_start, rule.block_end)
        else {
            continue;
        };
        let declarations =
            collect_simple_declarations_in_block(tokens, block_start_index, block_end_index);
        for declaration in declarations {
            if !is_static_color_reference_property(&declaration.property) {
                continue;
            }
            let Some((light_value, dark_value)) =
                substitute_light_dark_references_in_value(&declaration.value)
            else {
                continue;
            };
            replacements.push((
                declaration.start,
                declaration.end,
                format!("{}: {light_value};", declaration.property),
            ));
            insertions.push((
                rule.end,
                format!(
                    " @media (prefers-color-scheme: dark) {{ {} {{ {}: {dark_value}; }} }}",
                    rule.selector, declaration.property
                ),
            ));
        }
    }

    if replacements.is_empty() && insertions.is_empty() {
        return (source.to_string(), 0);
    }

    let mut output = String::with_capacity(source.len());
    let mut cursor = 0;
    let mut insertion_index = 0;
    for (start, end, replacement) in &replacements {
        while insertion_index < insertions.len() && insertions[insertion_index].0 <= *start {
            let (position, insertion) = &insertions[insertion_index];
            if *position > cursor {
                output.push_str(&source[cursor..*position]);
                cursor = *position;
            }
            output.push_str(insertion);
            insertion_index += 1;
        }
        if *start > cursor {
            output.push_str(&source[cursor..*start]);
        }
        output.push_str(replacement);
        cursor = *end;
    }
    while insertion_index < insertions.len() {
        let (position, insertion) = &insertions[insertion_index];
        if *position > cursor {
            output.push_str(&source[cursor..*position]);
            cursor = *position;
        }
        output.push_str(insertion);
        insertion_index += 1;
    }
    if cursor < source.len() {
        output.push_str(&source[cursor..]);
    }

    (output, replacements.len())
}

pub(crate) fn lower_css_color_mix_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    lower_static_color_function_references_with_lexer(
        source,
        dialect,
        &[("color-mix", parse_color_mix_value)],
        StaticColorLoweringTraversal::NestedBlocks,
    )
}

pub(crate) fn lower_css_oklab_oklch_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    lower_static_color_function_references_with_lexer(
        source,
        dialect,
        &[
            ("oklab", parse_oklab_oklch_value),
            ("oklch", parse_oklab_oklch_value),
        ],
        StaticColorLoweringTraversal::NestedBlocks,
    )
}

pub(crate) fn lower_css_color_function_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    lower_static_color_function_references_with_lexer(
        source,
        dialect,
        &[("color", parse_color_function_value)],
        StaticColorLoweringTraversal::SkipBlock,
    )
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StaticColorLoweringTraversal {
    NestedBlocks,
    SkipBlock,
}

fn lower_static_color_function_references_with_lexer(
    source: &str,
    dialect: StyleDialect,
    functions: &[StaticCssFunctionSpec<'_>],
    traversal: StaticColorLoweringTraversal,
) -> (String, usize) {
    let lexed = lex(source, dialect);
    let tokens = lexed.tokens();
    let mut replacements = Vec::new();
    let mut index = 0;

    while index < tokens.len() {
        if tokens[index].kind == SyntaxKind::LeftBrace
            && let Some(close_index) = matching_right_brace_index(tokens, index)
        {
            let declarations = collect_simple_declarations_in_block(tokens, index, close_index);
            for declaration in declarations {
                if !is_static_color_reference_property(&declaration.property) {
                    continue;
                }
                let Some(replacement_value) = substitute_static_css_function_references_in_value(
                    &declaration.value,
                    functions,
                ) else {
                    continue;
                };
                replacements.push((
                    declaration.start,
                    declaration.end,
                    format_replacement_declaration_like_source(
                        source,
                        &declaration,
                        &replacement_value,
                    ),
                ));
            }
            index = match traversal {
                StaticColorLoweringTraversal::NestedBlocks => index + 1,
                StaticColorLoweringTraversal::SkipBlock => close_index + 1,
            };
            continue;
        }
        index += 1;
    }

    if replacements.is_empty() {
        return (source.to_string(), 0);
    }

    let mut output = String::with_capacity(source.len());
    let mut cursor = 0;
    for (start, end, replacement) in &replacements {
        if *start > cursor {
            output.push_str(&source[cursor..*start]);
        }
        output.push_str(replacement);
        cursor = *end;
    }
    if cursor < source.len() {
        output.push_str(&source[cursor..]);
    }

    (output, replacements.len())
}

fn parse_light_dark_value(value: &str) -> Option<(String, String)> {
    let arguments = parse_whole_function_value_arguments(value, "light-dark")?;
    let [light, dark] = arguments.as_slice() else {
        return None;
    };
    if light.is_empty() || dark.is_empty() {
        return None;
    }
    Some((light.clone(), dark.clone()))
}

fn substitute_light_dark_references_in_value(value: &str) -> Option<(String, String)> {
    let mut light_output = String::with_capacity(value.len());
    let mut dark_output = String::with_capacity(value.len());
    let mut cursor = 0usize;
    let mut index = 0usize;
    let mut quote: Option<char> = None;
    let mut changed = false;

    while index < value.len() {
        let ch = value[index..].chars().next()?;

        if let Some(quote_ch) = quote {
            index += ch.len_utf8();
            if ch == '\\' {
                let escaped = value[index..].chars().next()?;
                index += escaped.len_utf8();
            } else if ch == quote_ch {
                quote = None;
            }
            continue;
        }

        match ch {
            '"' | '\'' => {
                quote = Some(ch);
                index += ch.len_utf8();
            }
            _ if value[index..]
                .get(.."light-dark(".len())
                .is_some_and(|text| text.eq_ignore_ascii_case("light-dark(")) =>
            {
                let left_paren_index = index + "light-dark".len();
                let Some(close_index) = matching_function_call_end(value, left_paren_index) else {
                    index += ch.len_utf8();
                    continue;
                };
                let function_value = &value[index..close_index + ')'.len_utf8()];
                let Some((light_value, dark_value)) = parse_light_dark_value(function_value) else {
                    index += ch.len_utf8();
                    continue;
                };
                light_output.push_str(&value[cursor..index]);
                dark_output.push_str(&value[cursor..index]);
                light_output.push_str(&light_value);
                dark_output.push_str(&dark_value);
                index = close_index + ')'.len_utf8();
                cursor = index;
                changed = true;
            }
            _ => {
                index += ch.len_utf8();
            }
        }
    }

    if !changed {
        return None;
    }
    light_output.push_str(&value[cursor..]);
    dark_output.push_str(&value[cursor..]);
    Some((light_output, dark_output))
}