omena-scss-eval 0.3.0

Oracle-first SCSS and Less value evaluator rail for Omena CSS
Documentation
use cstree::syntax::SyntaxNode;
use omena_parser::StyleDialect;
use omena_syntax::SyntaxKind;
use omena_transform_cst::StableNodeKeyV0;

use super::model::OmenaScssEvalControlFlowBlockV0;

pub(super) fn control_flow_blocks_from_cst(
    source: &str,
    root: &SyntaxNode<SyntaxKind>,
    dialect: StyleDialect,
) -> Vec<OmenaScssEvalControlFlowBlockV0> {
    root.descendants()
        .filter_map(|node| {
            control_flow_block_from_cst_node(source, node, dialect)
                .or_else(|| native_css_if_function_block_from_cst_node(source, node, dialect))
        })
        .collect()
}

fn control_flow_block_from_cst_node(
    source: &str,
    node: &SyntaxNode<SyntaxKind>,
    dialect: StyleDialect,
) -> Option<OmenaScssEvalControlFlowBlockV0> {
    if !cst_control_flow_kind_matches_dialect(node.kind(), dialect) {
        return None;
    }
    let kind = scss_control_block_kind(node.kind())?;
    let has_back_edge = scss_control_block_has_back_edge(node.kind());
    let (at_rule_name, source_span_start, header_start) = cst_at_keyword_name_and_span(node)?;
    let source_span_end = u32::from(node.text_range().end()) as usize;
    let header_text = cst_control_flow_header_text(source, node, header_start);
    let successor_count = scss_control_block_successor_count(node.kind(), header_text.as_str());
    Some(OmenaScssEvalControlFlowBlockV0 {
        node_key: scss_eval_stable_node_key(
            "scss-control",
            kind,
            source_span_start,
            source_span_end,
        ),
        kind,
        at_rule_name,
        header_text,
        source_span_start,
        source_span_end,
        successor_count,
        has_back_edge,
    })
}

fn cst_control_flow_kind_matches_dialect(kind: SyntaxKind, dialect: StyleDialect) -> bool {
    matches!(
        (dialect, kind),
        (
            StyleDialect::Css,
            SyntaxKind::WhenRule | SyntaxKind::ElseRule | SyntaxKind::IfRule
        ) | (
            StyleDialect::Scss | StyleDialect::Sass,
            SyntaxKind::ScssControlIf
                | SyntaxKind::ScssControlElse
                | SyntaxKind::ScssControlFor
                | SyntaxKind::ScssControlEach
                | SyntaxKind::ScssControlWhile
        )
    )
}

fn cst_at_keyword_name_and_span(node: &SyntaxNode<SyntaxKind>) -> Option<(String, usize, usize)> {
    for token in node
        .descendants_with_tokens()
        .filter_map(|element| element.into_token())
    {
        if token.kind() != SyntaxKind::AtKeyword {
            continue;
        }
        let name = syntax_token_text(token)?;
        let source_span_start = u32::from(token.text_range().start()) as usize;
        let header_start = u32::from(token.text_range().end()) as usize;
        return Some((name, source_span_start, header_start));
    }
    None
}

fn cst_control_flow_header_text(
    source: &str,
    node: &SyntaxNode<SyntaxKind>,
    header_start: usize,
) -> String {
    let header_end = node
        .descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .find(|token| {
            matches!(
                token.kind(),
                SyntaxKind::LeftBrace
                    | SyntaxKind::Semicolon
                    | SyntaxKind::SassIndent
                    | SyntaxKind::SassOptionalSemicolon
            )
        })
        .map(|token| u32::from(token.text_range().start()) as usize)
        .unwrap_or_else(|| u32::from(node.text_range().end()) as usize);
    source
        .get(header_start..header_end)
        .unwrap_or("")
        .trim()
        .to_string()
}

fn syntax_token_text(token: &cstree::syntax::SyntaxToken<SyntaxKind>) -> Option<String> {
    if let Some(resolver) = token.resolver() {
        Some(token.resolve_text(&**resolver).to_string())
    } else {
        token.static_text().map(str::to_string)
    }
}

fn native_css_if_function_block_from_cst_node(
    source: &str,
    node: &SyntaxNode<SyntaxKind>,
    dialect: StyleDialect,
) -> Option<OmenaScssEvalControlFlowBlockV0> {
    if dialect != StyleDialect::Css
        || !matches!(
            node.kind(),
            SyntaxKind::FunctionCall | SyntaxKind::IfFunction
        )
    {
        return None;
    }
    let tokens = node
        .descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .filter(|token| !token.kind().is_trivia())
        .collect::<Vec<_>>();
    let [function_name, left_paren, ..] = tokens.as_slice() else {
        return None;
    };
    let source_span_start = u32::from(function_name.text_range().start()) as usize;
    let function_name_end = u32::from(function_name.text_range().end()) as usize;
    let function_name_text = source.get(source_span_start..function_name_end)?;
    if function_name.kind() != SyntaxKind::Ident
        || !function_name_text.eq_ignore_ascii_case("if")
        || left_paren.kind() != SyntaxKind::LeftParen
    {
        return None;
    }
    let right_paren = tokens
        .iter()
        .rev()
        .find(|token| token.kind() == SyntaxKind::RightParen)?;
    let source_span_end = u32::from(right_paren.text_range().end()) as usize;
    let header_start = u32::from(left_paren.text_range().end()) as usize;
    let header_end = u32::from(right_paren.text_range().start()) as usize;
    let header_text = source
        .get(header_start..header_end)
        .unwrap_or("")
        .trim()
        .to_string();

    Some(OmenaScssEvalControlFlowBlockV0 {
        node_key: scss_eval_stable_node_key(
            "css-value-control",
            "branchIf",
            source_span_start,
            source_span_end,
        ),
        kind: "branchIf",
        at_rule_name: "if()".to_string(),
        header_text,
        source_span_start,
        source_span_end,
        successor_count: 2,
        has_back_edge: false,
    })
}

pub(super) fn scss_eval_stable_node_key(
    prefix: &str,
    kind: &str,
    source_span_start: usize,
    source_span_end: usize,
) -> StableNodeKeyV0 {
    StableNodeKeyV0(format!(
        "{prefix}:{kind}@{source_span_start}..{source_span_end}"
    ))
}

pub(super) fn scss_control_block_kind(kind: SyntaxKind) -> Option<&'static str> {
    match kind {
        SyntaxKind::ScssControlIf | SyntaxKind::WhenRule | SyntaxKind::IfRule => Some("branchIf"),
        SyntaxKind::ScssControlElse | SyntaxKind::ElseRule => Some("branchElse"),
        SyntaxKind::ScssControlFor | SyntaxKind::ScssControlEach | SyntaxKind::ScssControlWhile => {
            Some("loop")
        }
        _ => None,
    }
}

pub(super) const fn scss_control_block_has_back_edge(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::ScssControlFor | SyntaxKind::ScssControlEach | SyntaxKind::ScssControlWhile
    )
}

pub(super) fn scss_control_block_successor_count(kind: SyntaxKind, header: &str) -> usize {
    match kind {
        SyntaxKind::ScssControlIf | SyntaxKind::WhenRule | SyntaxKind::IfRule => 2,
        SyntaxKind::ScssControlElse if scss_else_if_header_condition(header).is_some() => 2,
        SyntaxKind::ScssControlElse | SyntaxKind::ElseRule => 1,
        SyntaxKind::ScssControlFor | SyntaxKind::ScssControlEach | SyntaxKind::ScssControlWhile => {
            2
        }
        _ => 0,
    }
}

pub(super) fn scss_else_if_header_condition(header: &str) -> Option<&str> {
    let trimmed = header.trim();
    let prefix = trimmed.get(..2)?;
    let rest = trimmed.get(2..)?;
    if !prefix.eq_ignore_ascii_case("if") || !rest.chars().next().is_some_and(char::is_whitespace) {
        return None;
    }
    Some(rest.trim()).filter(|condition| !condition.is_empty())
}