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, SyntaxToken};
use omena_abstract_value::{
    AbstractCssValueV0, abstract_css_value_from_text, join_abstract_css_values,
};
use omena_parser::{ParseEntryPoint, StyleDialect, parse_entry_point};
use omena_syntax::SyntaxKind;

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

use super::lexical::{LexicalScssBindings, static_scss_metadata_exists_call_may_need_resolution};
use super::variables::{
    canonical_scss_variable_name, static_scss_binding_value, variable_name_end,
    variable_names_in_text,
};

pub(super) fn scss_header_value(
    header: &str,
    lexical_bindings: &LexicalScssBindings,
    position: usize,
) -> AbstractCssValueV0 {
    let visible_bindings = lexical_bindings.visible_at(position);
    scss_header_value_with_bindings(header, lexical_bindings, position, &visible_bindings)
}

pub(super) fn scss_header_value_with_bindings(
    header: &str,
    lexical_bindings: &LexicalScssBindings,
    position: usize,
    visible_bindings: &BTreeMap<String, AbstractCssValueV0>,
) -> AbstractCssValueV0 {
    let reduced_header = reduce_static_scss_metadata_with_context(
        header,
        |name| lexical_bindings.visible_function_metadata_exists(name, position),
        |name| lexical_bindings.visible_mixin_metadata_exists(name, position),
        |name| lexical_bindings.visible_variable_metadata_exists(name, position),
        |name| lexical_bindings.global_variable_metadata_exists(name, position),
    );
    match reduced_header {
        Some(header) => scss_header_value_from_bindings(header.as_str(), visible_bindings),
        None if static_scss_metadata_exists_call_may_need_resolution(header) => {
            AbstractCssValueV0::Top
        }
        None => scss_header_value_from_bindings(header, visible_bindings),
    }
}

pub(super) fn scss_header_value_from_bindings(
    header: &str,
    lexical_bindings: &BTreeMap<String, AbstractCssValueV0>,
) -> AbstractCssValueV0 {
    let variables = variable_names_in_text(header);
    if variables.is_empty() {
        return static_scss_header_abstract_value(header);
    }
    if let Some(value) = scss_header_value_from_binding_combinations(header, lexical_bindings) {
        return value;
    }
    if let Some(substituted) = substitute_static_scss_header_variables(header, lexical_bindings) {
        return static_scss_header_abstract_value(substituted.as_str());
    }
    variables
        .iter()
        .map(|name| {
            static_scss_binding_value(lexical_bindings, name)
                .cloned()
                .unwrap_or(AbstractCssValueV0::Top)
        })
        .fold(AbstractCssValueV0::Bottom, |acc, value| {
            join_abstract_css_values(&acc, &value)
        })
}

fn scss_header_value_from_binding_combinations(
    header: &str,
    lexical_bindings: &BTreeMap<String, AbstractCssValueV0>,
) -> Option<AbstractCssValueV0> {
    let variables = variable_names_in_text(header);
    if variables.is_empty() {
        return Some(static_scss_header_abstract_value(header));
    }
    let mut combinations = vec![BTreeMap::<String, String>::new()];
    for variable in variables {
        let values = static_scss_binding_value(lexical_bindings, variable.as_str())?;
        let values = static_scss_header_value_texts(values)?;
        if values.is_empty() {
            return None;
        }
        let mut next = Vec::new();
        for combination in combinations {
            for value in &values {
                let mut combination = combination.clone();
                combination.insert(
                    canonical_scss_variable_name(variable.as_str()),
                    value.clone(),
                );
                next.push(combination);
                if next.len() > 64 {
                    return None;
                }
            }
        }
        combinations = next;
    }
    combinations
        .into_iter()
        .map(|combination| substitute_static_scss_header_variable_combination(header, &combination))
        .collect::<Option<Vec<_>>>()
        .map(|headers| {
            headers
                .into_iter()
                .map(|header| static_scss_header_abstract_value(header.as_str()))
                .fold(AbstractCssValueV0::Bottom, |acc, value| {
                    join_abstract_css_values(&acc, &value)
                })
        })
}

fn static_scss_header_value_texts(value: &AbstractCssValueV0) -> Option<Vec<String>> {
    match value {
        AbstractCssValueV0::Exact { value, .. } | AbstractCssValueV0::Raw { value } => {
            Some(vec![value.clone()])
        }
        AbstractCssValueV0::FiniteSet { values, .. } => Some(values.clone()),
        AbstractCssValueV0::Bottom | AbstractCssValueV0::Top => None,
    }
}

fn substitute_static_scss_header_variable_combination(
    header: &str,
    bindings: &BTreeMap<String, String>,
) -> Option<String> {
    let mut output = String::with_capacity(header.len());
    let mut index = 0usize;
    while index < header.len() {
        let ch = header[index..].chars().next()?;
        if ch != '$' {
            output.push(ch);
            index += ch.len_utf8();
            continue;
        }
        let name_end = variable_name_end(header, index + ch.len_utf8());
        let name = header.get(index..name_end)?;
        let value = bindings.get(canonical_scss_variable_name(name).as_str())?;
        output.push_str(value);
        index = name_end.max(index + ch.len_utf8());
    }
    Some(output)
}

pub(super) fn static_scss_header_abstract_value(value: &str) -> AbstractCssValueV0 {
    let reduced = reduce_static_scss_value(value.to_string());
    let trimmed = reduced.trim();
    if static_scss_header_is_boolean_expression(trimmed)
        && let Some(truthy) = static_scss_literal_truthiness(trimmed)
    {
        return abstract_css_value_from_text(if truthy { "true" } else { "false" });
    }
    abstract_css_value_from_text(trimmed)
}

fn static_scss_header_is_boolean_expression(value: &str) -> bool {
    let trimmed = value.trim();
    let lower = trimmed.to_ascii_lowercase();
    if matches!(lower.as_str(), "true" | "false" | "null") {
        return true;
    }
    let parsed = parse_entry_point(trimmed, StyleDialect::Scss, ParseEntryPoint::Value);
    if !parsed.errors().is_empty() {
        return false;
    }
    let root = parsed.syntax();
    static_scss_header_boolean_expression_node(&root)
}

fn static_scss_header_boolean_expression_node(node: &SyntaxNode<SyntaxKind>) -> bool {
    match node.kind() {
        SyntaxKind::Root => node
            .children()
            .any(static_scss_header_boolean_expression_node),
        SyntaxKind::Value
        | SyntaxKind::ParenthesizedExpression
        | SyntaxKind::ScssCondition
        | SyntaxKind::LessCondition => static_scss_header_wrapped_boolean_expression(node),
        SyntaxKind::UnaryExpression => static_scss_header_unary_is_boolean(node),
        SyntaxKind::BinaryExpression => static_scss_header_binary_is_boolean(node),
        _ => false,
    }
}

fn static_scss_header_wrapped_boolean_expression(node: &SyntaxNode<SyntaxKind>) -> bool {
    if static_scss_header_node_starts_with_not_operator(node) {
        return true;
    }
    let expression_children = node
        .children()
        .filter(|child| static_scss_header_expression_node_kind(child.kind()))
        .collect::<Vec<_>>();
    match expression_children.as_slice() {
        [child] => static_scss_header_boolean_expression_node(child),
        [operator, _] if static_scss_header_node_is_not_operator(operator) => true,
        _ => false,
    }
}

fn static_scss_header_unary_is_boolean(node: &SyntaxNode<SyntaxKind>) -> bool {
    static_scss_header_first_non_trivia_token(node)
        .is_some_and(|token| static_scss_header_token_is_not_operator(&token))
}

fn static_scss_header_binary_is_boolean(node: &SyntaxNode<SyntaxKind>) -> bool {
    let children = node
        .children()
        .filter(|child| static_scss_header_expression_node_kind(child.kind()))
        .collect::<Vec<_>>();
    let [left, right] = children.as_slice() else {
        return false;
    };
    static_scss_header_binary_operator_is_boolean(node, left, right)
}

fn static_scss_header_binary_operator_is_boolean(
    node: &SyntaxNode<SyntaxKind>,
    left: &SyntaxNode<SyntaxKind>,
    right: &SyntaxNode<SyntaxKind>,
) -> bool {
    let start = u32::from(left.text_range().end()) as usize;
    let end = u32::from(right.text_range().start()) as usize;
    let tokens = node
        .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;
            start <= token_start && token_end <= end && !token.kind().is_trivia()
        })
        .collect::<Vec<_>>();

    match tokens.as_slice() {
        [token] => match token.kind() {
            SyntaxKind::KeywordOr
            | SyntaxKind::KeywordAnd
            | SyntaxKind::DoubleAmpersand
            | SyntaxKind::ColumnCombinator
            | SyntaxKind::LessThan
            | SyntaxKind::GreaterThan => true,
            SyntaxKind::Ident => {
                static_scss_header_token_text(token)
                    .as_deref()
                    .is_some_and(|text| {
                        text.eq_ignore_ascii_case("or") || text.eq_ignore_ascii_case("and")
                    })
            }
            _ => false,
        },
        [first, second] if second.kind() == SyntaxKind::Equals => {
            matches!(
                first.kind(),
                SyntaxKind::LessThan | SyntaxKind::GreaterThan | SyntaxKind::Equals
            ) || (first.kind() == SyntaxKind::Delim
                && static_scss_header_token_text(first).as_deref() == Some("!"))
        }
        _ => false,
    }
}

fn static_scss_header_expression_node_kind(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::Value
            | SyntaxKind::BinaryExpression
            | SyntaxKind::UnaryExpression
            | SyntaxKind::ParenthesizedExpression
            | SyntaxKind::ScssList
            | SyntaxKind::ScssCondition
            | SyntaxKind::LessCondition
            | SyntaxKind::IdentifierValue
            | SyntaxKind::StringValue
            | SyntaxKind::UnicodeRangeValue
            | SyntaxKind::NumberValue
            | SyntaxKind::PercentageValue
            | SyntaxKind::DimensionValue
            | SyntaxKind::ColorValue
            | SyntaxKind::UrlValue
            | SyntaxKind::ComponentValue
            | SyntaxKind::CustomPropertyValue
            | SyntaxKind::AttributeValue
    )
}

fn static_scss_header_node_is_not_operator(node: &SyntaxNode<SyntaxKind>) -> bool {
    static_scss_header_first_non_trivia_token(node)
        .is_some_and(|token| static_scss_header_token_is_not_operator(&token))
}

fn static_scss_header_node_starts_with_not_operator(node: &SyntaxNode<SyntaxKind>) -> bool {
    let mut tokens = node
        .descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .filter(|token| !token.kind().is_trivia());
    tokens
        .next()
        .is_some_and(static_scss_header_token_is_not_operator)
        && tokens.next().is_some()
}

fn static_scss_header_token_is_not_operator(token: &SyntaxToken<SyntaxKind>) -> bool {
    matches!(token.kind(), SyntaxKind::KeywordNot)
        || (token.kind() == SyntaxKind::Ident
            && static_scss_header_token_text(token)
                .as_deref()
                .is_some_and(|text| text.eq_ignore_ascii_case("not")))
}

fn static_scss_header_first_non_trivia_token(
    node: &SyntaxNode<SyntaxKind>,
) -> Option<SyntaxToken<SyntaxKind>> {
    node.descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .find(|token| !token.kind().is_trivia())
        .cloned()
}

fn static_scss_header_token_text(token: &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)
    }
}

pub(super) fn substitute_static_scss_header_variables(
    header: &str,
    lexical_bindings: &BTreeMap<String, AbstractCssValueV0>,
) -> Option<String> {
    let mut output = String::with_capacity(header.len());
    let mut index = 0usize;
    while index < header.len() {
        let ch = header[index..].chars().next()?;
        if ch != '$' {
            output.push(ch);
            index += ch.len_utf8();
            continue;
        }
        let name_end = variable_name_end(header, index + ch.len_utf8());
        let name = header.get(index..name_end)?;
        let value = static_scss_binding_value(lexical_bindings, name)
            .and_then(single_static_scss_header_value_text)?;
        output.push_str(value);
        index = name_end.max(index + ch.len_utf8());
    }
    Some(output)
}

pub(super) fn single_static_scss_header_value_text(value: &AbstractCssValueV0) -> Option<&str> {
    match value {
        AbstractCssValueV0::Exact { value, .. } | AbstractCssValueV0::Raw { value } => {
            Some(value.as_str())
        }
        AbstractCssValueV0::FiniteSet { values, .. } if values.len() == 1 => {
            values.first().map(String::as_str)
        }
        AbstractCssValueV0::Bottom
        | AbstractCssValueV0::Top
        | AbstractCssValueV0::FiniteSet { .. } => None,
    }
}

#[cfg(test)]
mod tests {
    use omena_abstract_value::abstract_css_value_from_text;

    use super::*;

    #[test]
    fn static_scss_header_boolean_detection_uses_cst_expression_shape() {
        assert_eq!(
            static_scss_header_abstract_value("red"),
            abstract_css_value_from_text("red")
        );
        assert_eq!(
            static_scss_header_abstract_value("true and false"),
            abstract_css_value_from_text("false")
        );
        assert_eq!(
            static_scss_header_abstract_value("not false"),
            abstract_css_value_from_text("true")
        );
        assert_eq!(
            static_scss_header_abstract_value("1px < 2px"),
            abstract_css_value_from_text("true")
        );
    }
}