omena-scss-eval 0.3.0

Oracle-first SCSS and Less value evaluator rail for Omena CSS
Documentation
use std::collections::BTreeMap;

use cstree::syntax::SyntaxNode;
use omena_abstract_value::{AbstractCssValueV0, abstract_css_value_from_text};
use omena_parser::StyleDialect;
use omena_syntax::SyntaxKind;

use crate::{
    scss_metadata::reduce_static_scss_metadata_with_context, value_eval::reduce_static_scss_value,
};

use super::{
    analysis_model::{ScssCallReturnCandidate, ScssGlobalVariableDeclaration, ScssReturnCondition},
    blocks::{control_flow_blocks_from_cst, scss_else_if_header_condition},
    call_resolution::{
        scss_visible_function_declaration_exists, scss_visible_mixin_declaration_exists,
    },
    lexical::{
        scss_global_variable_metadata_exists, static_scss_metadata_exists_call_may_need_resolution,
    },
    loop_values::{ScssControlFlowLoopContext, control_flow_block_body_text},
    model::OmenaScssEvalCallReturnNodeV0,
    variables::canonical_scss_variable_name,
};

#[derive(Debug, Clone, PartialEq, Eq)]
struct ScssCstBranchBlock {
    at_rule_name: String,
    condition_text: Option<String>,
    source_span_start: usize,
    source_span_end: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ScssCstLoopBlock {
    header_text: String,
    body_text: Option<String>,
    source_span_start: usize,
    source_span_end: usize,
}

pub(super) fn collect_scss_return_candidates_from_cst(
    source: &str,
    root: &SyntaxNode<SyntaxKind>,
    dialect: StyleDialect,
) -> Vec<ScssCallReturnCandidate> {
    let blocks = control_flow_blocks_from_cst(source, root, dialect);
    let branch_blocks = cst_branch_blocks(blocks.as_slice());
    let loop_blocks = cst_loop_blocks(source, blocks.as_slice());
    root.descendants()
        .filter(|node| node.kind() == SyntaxKind::ScssReturnRule)
        .filter_map(|node| {
            let at_keyword = cst_first_at_keyword_token(node)?;
            let source_span_start = u32::from(at_keyword.text_range().start()) as usize;
            let source_span_end = u32::from(at_keyword.text_range().end()) as usize;
            let return_text = cst_return_text(source, node, source_span_end);
            let return_loop_contexts = cst_return_loop_contexts(&loop_blocks, source_span_start);
            let return_loop_header_texts = return_loop_contexts
                .iter()
                .map(|context| context.header_text.clone())
                .collect::<Vec<_>>();
            let return_loop_body_texts = return_loop_contexts
                .iter()
                .map(|context| context.body_text.clone().unwrap_or_default())
                .collect::<Vec<_>>();
            let return_inside_loop_control_flow = !return_loop_contexts.is_empty();
            let return_loop_header_text = return_loop_header_texts.last().cloned();
            let return_value = if return_inside_loop_control_flow {
                Some(AbstractCssValueV0::Top)
            } else {
                return_text
                    .as_deref()
                    .map(static_scss_return_abstract_value)
            };
            let return_condition = cst_return_condition(root, &branch_blocks, source_span_start);
            Some(ScssCallReturnCandidate {
                kind: "functionReturn",
                symbol_kind: "return",
                role: "return",
                name: None,
                namespace: None,
                parameter_names: Vec::new(),
                parameter_values: Vec::new(),
                local_binding_values: Vec::new(),
                argument_values: Vec::new(),
                return_text,
                return_value,
                body_has_control_flow: false,
                body_has_loop_control_flow: false,
                return_inside_loop_control_flow,
                return_loop_header_text,
                return_loop_header_texts,
                return_loop_body_texts,
                return_condition_text: return_condition
                    .as_ref()
                    .and_then(|condition| condition.condition_text.clone()),
                return_negated_condition_texts: return_condition
                    .map(|condition| condition.negated_condition_texts)
                    .unwrap_or_default(),
                source_span_start,
                source_span_end,
            })
        })
        .collect()
}

fn cst_branch_blocks(
    blocks: &[super::model::OmenaScssEvalControlFlowBlockV0],
) -> Vec<ScssCstBranchBlock> {
    blocks
        .iter()
        .filter(|block| block.kind.starts_with("branch"))
        .map(|block| {
            let condition_text = if block.kind == "branchIf" {
                Some(block.header_text.clone()).filter(|header| !header.is_empty())
            } else {
                scss_else_if_header_condition(block.header_text.as_str()).map(ToString::to_string)
            };
            ScssCstBranchBlock {
                at_rule_name: block.at_rule_name.to_ascii_lowercase(),
                condition_text,
                source_span_start: block.source_span_start,
                source_span_end: block.source_span_end,
            }
        })
        .collect()
}

fn cst_loop_blocks(
    source: &str,
    blocks: &[super::model::OmenaScssEvalControlFlowBlockV0],
) -> Vec<ScssCstLoopBlock> {
    blocks
        .iter()
        .filter(|block| block.kind == "loop")
        .map(|block| ScssCstLoopBlock {
            header_text: block.header_text.clone(),
            body_text: control_flow_block_body_text(source, block).map(ToString::to_string),
            source_span_start: block.source_span_start,
            source_span_end: block.source_span_end,
        })
        .collect()
}

fn cst_return_loop_contexts(
    loop_blocks: &[ScssCstLoopBlock],
    return_start: usize,
) -> Vec<ScssControlFlowLoopContext> {
    let mut enclosing = loop_blocks
        .iter()
        .filter(|block| {
            block.source_span_start < return_start && return_start < block.source_span_end
        })
        .collect::<Vec<_>>();
    enclosing.sort_by(|left, right| {
        let left_span = left.source_span_end.saturating_sub(left.source_span_start);
        let right_span = right
            .source_span_end
            .saturating_sub(right.source_span_start);
        right_span.cmp(&left_span)
    });
    enclosing
        .into_iter()
        .map(|block| ScssControlFlowLoopContext {
            header_text: block.header_text.clone(),
            body_text: block.body_text.clone(),
        })
        .collect()
}

fn cst_return_condition(
    root: &SyntaxNode<SyntaxKind>,
    branch_blocks: &[ScssCstBranchBlock],
    return_start: usize,
) -> Option<ScssReturnCondition> {
    let (block_index, block) = branch_blocks
        .iter()
        .enumerate()
        .filter(|(_, block)| {
            block.source_span_start < return_start && return_start < block.source_span_end
        })
        .min_by_key(|(_, block)| {
            block
                .source_span_end
                .saturating_sub(block.source_span_start)
        })?;
    let negated_condition_texts =
        previous_cst_branch_condition_texts(root, branch_blocks, block_index);
    Some(ScssReturnCondition {
        condition_text: block.condition_text.clone(),
        negated_condition_texts,
    })
}

fn previous_cst_branch_condition_texts(
    root: &SyntaxNode<SyntaxKind>,
    branch_blocks: &[ScssCstBranchBlock],
    block_index: usize,
) -> Vec<String> {
    let Some(current_block) = branch_blocks.get(block_index) else {
        return Vec::new();
    };
    if current_block.at_rule_name != "@else" {
        return Vec::new();
    }

    let mut conditions = Vec::new();
    let mut cursor = current_block.source_span_start;
    for candidate in branch_blocks[..block_index].iter().rev() {
        if candidate.source_span_end > cursor
            || !cst_source_between_is_trivia(root, candidate.source_span_end, cursor)
        {
            continue;
        }
        if let Some(condition) = candidate.condition_text.clone() {
            conditions.push(condition);
        }
        if candidate.at_rule_name == "@if" {
            conditions.reverse();
            return conditions;
        }
        cursor = candidate.source_span_start;
    }
    Vec::new()
}

fn cst_source_between_is_trivia(root: &SyntaxNode<SyntaxKind>, start: usize, end: usize) -> bool {
    if start > end {
        return false;
    }
    root.descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .filter(|token| {
            let token_start = u32::from(token.text_range().start()) as usize;
            let token_end = u32::from(token.text_range().end()) as usize;
            token_start < end && token_end > start
        })
        .all(|token| cst_token_is_trivia(token.kind()))
}

fn cst_token_is_trivia(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::Whitespace
            | SyntaxKind::LineComment
            | SyntaxKind::BlockComment
            | SyntaxKind::ScssSilentComment
            | SyntaxKind::SassIndentedNewline
            | SyntaxKind::SassDedent
    )
}

fn cst_first_at_keyword_token(
    node: &SyntaxNode<SyntaxKind>,
) -> Option<cstree::syntax::SyntaxToken<SyntaxKind>> {
    node.descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .find(|token| token.kind() == SyntaxKind::AtKeyword)
        .cloned()
}

fn cst_return_text(
    source: &str,
    node: &SyntaxNode<SyntaxKind>,
    value_start: usize,
) -> Option<String> {
    let value_end = node
        .descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .find(|token| {
            let token_start = u32::from(token.text_range().start()) as usize;
            token_start >= value_start
                && matches!(
                    token.kind(),
                    SyntaxKind::Semicolon
                        | SyntaxKind::SassOptionalSemicolon
                        | SyntaxKind::SassIndentedNewline
                        | SyntaxKind::RightBrace
                )
        })
        .map(|token| u32::from(token.text_range().start()) as usize)
        .unwrap_or_else(|| u32::from(node.text_range().end()) as usize);
    source
        .get(value_start..value_end)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

pub(super) fn static_scss_return_abstract_value(value: &str) -> AbstractCssValueV0 {
    abstract_css_value_from_text(reduce_static_scss_value(value.to_string()).as_str())
}

pub(super) fn static_scss_return_abstract_value_with_context(
    value: &str,
    position: usize,
    nodes: &[OmenaScssEvalCallReturnNodeV0],
    bindings: Option<&BTreeMap<String, AbstractCssValueV0>>,
    global_variable_declarations: &[ScssGlobalVariableDeclaration],
) -> AbstractCssValueV0 {
    let reduced = reduce_static_scss_metadata_with_context(
        value,
        |name| scss_visible_function_declaration_exists(name, position, nodes).then_some(true),
        |name| scss_visible_mixin_declaration_exists(name, position, nodes).then_some(true),
        |name| {
            bindings
                .map(|bindings| bindings.contains_key(canonical_scss_variable_name(name).as_str()))
        },
        |name| scss_global_variable_metadata_exists(name, position, global_variable_declarations),
    );
    let value = match reduced {
        Some(reduced) => reduced,
        None if static_scss_metadata_exists_call_may_need_resolution(value) => {
            return AbstractCssValueV0::Top;
        }
        None => value.to_string(),
    };
    static_scss_return_abstract_value(value.as_str())
}