zen-engine 2.0.0

Business rules engine
Documentation
use std::cell::RefCell;

use ahash::HashSet;
use zen_expression::intellisense::AstMetadata;
use zen_expression::parser::Node;

use crate::policy::blocks::BlockKind;
use crate::workspace::types::{
    Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Span,
};

use super::{AstOps, LintContext, LintRule};

pub(crate) struct PreferMatch;

struct ChainInfo {
    conditions: usize,
    scrutinee: Option<String>,
    span: Option<Span>,
}

impl PreferMatch {
    const MIN_CONDITIONS: usize = 2;

    fn inspect(root: &Node, metadata: &AstMetadata) -> Option<ChainInfo> {
        let node = AstOps::unwrap_parens(root);
        let Node::Conditional {
            condition,
            on_false,
            ..
        } = node
        else {
            return None;
        };

        let mut condition_paths = vec![Self::maximal_paths(condition)];
        let mut tail = AstOps::unwrap_parens(on_false);
        while let Node::Conditional {
            condition,
            on_false,
            ..
        } = tail
        {
            condition_paths.push(Self::maximal_paths(condition));
            tail = AstOps::unwrap_parens(on_false);
        }
        if condition_paths.len() < Self::MIN_CONDITIONS {
            return None;
        }

        let common = condition_paths
            .iter()
            .skip(1)
            .fold(condition_paths[0].clone(), |acc, paths| {
                acc.intersection(paths).cloned().collect()
            });
        let scrutinee = match common.len() {
            1 => common.into_iter().next(),
            _ => None,
        };

        Some(ChainInfo {
            conditions: condition_paths.len(),
            scrutinee,
            span: AstOps::span(metadata, node),
        })
    }

    fn maximal_paths(node: &Node) -> HashSet<String> {
        let all = RefCell::new(HashSet::default());
        node.walk(|n| {
            if let Some(path) = AstOps::dotted_path(n) {
                all.borrow_mut().insert(path);
            }
        });
        let all = all.into_inner();
        all.iter()
            .filter(|path| {
                !all.iter()
                    .any(|other| other.len() > path.len() && other.starts_with(&format!("{path}.")))
            })
            .cloned()
            .collect()
    }
}

impl LintRule for PreferMatch {
    fn check(&self, cx: &LintContext, out: &mut Vec<Diagnostic>) {
        for block in cx.rules() {
            let BlockKind::Expression(expression) = &block.kind else {
                continue;
            };
            if expression.value.is_empty() {
                continue;
            }
            let Some(Some(chain)) =
                cx.with_ast(&expression.value, ExpressionKind::Standard, Self::inspect)
            else {
                continue;
            };

            let subject = match &chain.scrutinee {
                Some(path) => format!(
                    "chained ternary with {} conditions on '{path}'",
                    chain.conditions
                ),
                None => format!("chained ternary with {} conditions", chain.conditions),
            };
            out.push(Diagnostic::hint(
                DiagnosticCode::PreferMatch,
                DiagnosticLocation::expression(
                    cx.target().clone(),
                    block.id.clone(),
                    block.id.clone(),
                    chain.span,
                ),
                format!("{subject} — a match block expresses this more clearly"),
            ));
        }
    }
}