omena-transform-passes 0.2.0

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

use crate::helpers::tokens::{
    is_comment_token, next_non_comment_token_kind, previous_non_comment_token_kind,
};

pub(crate) fn normalize_css_whitespace_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    let lexed = lex(source, dialect);
    let tokens = lexed.tokens();
    let mut output = String::with_capacity(source.len());
    let mut mutation_count = 0;

    for (index, token) in tokens.iter().enumerate() {
        if token.kind == SyntaxKind::Semicolon
            && matches!(
                next_non_comment_token_kind(tokens, index),
                Some(SyntaxKind::RightBrace)
            )
        {
            mutation_count += 1;
            continue;
        }

        if token.kind != SyntaxKind::Whitespace && token.kind != SyntaxKind::SassIndentedNewline {
            output.push_str(&token.text);
            continue;
        }

        let replacement = if whitespace_is_important_annotation_gap(tokens, index) {
            ""
        } else {
            whitespace_replacement_for_tokens(
                previous_non_comment_token_kind(tokens, index),
                next_non_comment_token_kind(tokens, index),
            )
        };
        if replacement != token.text {
            mutation_count += 1;
        }
        output.push_str(replacement);
    }

    (output, mutation_count)
}

fn whitespace_is_important_annotation_gap(tokens: &[LexedToken], index: usize) -> bool {
    let Some((previous_index, previous)) = previous_non_trivia_token(tokens, index) else {
        return false;
    };
    let Some((next_index, next)) = next_non_trivia_token(tokens, index) else {
        return false;
    };

    if next.kind == SyntaxKind::Important {
        return true;
    }

    if previous.kind == SyntaxKind::Delim
        && previous.text == "!"
        && next.kind == SyntaxKind::Ident
        && next.text.eq_ignore_ascii_case("important")
    {
        return true;
    }

    next.kind == SyntaxKind::Delim
        && next.text == "!"
        && previous_index < next_index
        && next_non_trivia_token(tokens, next_index).is_some_and(|(_, token)| {
            token.kind == SyntaxKind::Ident && token.text.eq_ignore_ascii_case("important")
        })
}

fn previous_non_trivia_token(tokens: &[LexedToken], index: usize) -> Option<(usize, &LexedToken)> {
    tokens[..index]
        .iter()
        .enumerate()
        .rev()
        .find(|(_, token)| !crate::helpers::tokens::is_trivia_token(token.kind))
}

fn next_non_trivia_token(tokens: &[LexedToken], index: usize) -> Option<(usize, &LexedToken)> {
    tokens
        .iter()
        .enumerate()
        .skip(index + 1)
        .find(|(_, token)| !crate::helpers::tokens::is_trivia_token(token.kind))
}

fn whitespace_replacement_for_tokens(
    previous: Option<SyntaxKind>,
    next: Option<SyntaxKind>,
) -> &'static str {
    match (previous, next) {
        (None, _) | (_, None) => "",
        (Some(previous), Some(next))
            if can_remove_whitespace_after(previous) || can_remove_whitespace_before(next) =>
        {
            ""
        }
        _ => " ",
    }
}

fn can_remove_whitespace_after(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::LeftBrace
            | SyntaxKind::RightBrace
            | SyntaxKind::LeftParen
            | SyntaxKind::LeftBracket
            | SyntaxKind::Comma
            | SyntaxKind::Colon
            | SyntaxKind::Semicolon
    )
}

fn can_remove_whitespace_before(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::LeftBrace
            | SyntaxKind::RightBrace
            | SyntaxKind::RightParen
            | SyntaxKind::RightBracket
            | SyntaxKind::Comma
            | SyntaxKind::Colon
            | SyntaxKind::Semicolon
    )
}

pub(crate) fn strip_css_comments_with_lexer(
    source: &str,
    dialect: StyleDialect,
) -> (String, usize) {
    let lexed = lex(source, dialect);
    let mut output = String::with_capacity(source.len());
    let mut cursor = 0;
    let mut removed_comment_count = 0;

    for token in lexed.tokens() {
        let start = u32::from(token.range.start()) as usize;
        let end = u32::from(token.range.end()) as usize;
        if start > cursor {
            output.push_str(&source[cursor..start]);
        }
        if is_comment_token(token.kind) {
            removed_comment_count += 1;
        } else {
            output.push_str(&source[start..end]);
        }
        cursor = end;
    }

    if cursor < source.len() {
        output.push_str(&source[cursor..]);
    }

    (output, removed_comment_count)
}