rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
#[cfg(test)]
use googletest::prelude::*;
use ra_ap_syntax::{AstNode, Edition, SourceFile, ast, ast::HasArgList};

use crate::{AstCtx, Example, Violation};

use super::super::support::binary_macro_argument_candidates;

const COMPLEX_MATCHES: &str = "complex-matches";
const MULTILINE_ARM_SPACING: &str = "multiline-arm-spacing";
const MIN_COMPLEX_ALTERNATIVES: usize = 3;

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "simple matches macro",
        code: "fn valid(value: Value) -> bool { matches!(value, Value::One | Value::Two) }",
        pass: true,
    },
    Example {
        label: "complex multiline matches macro",
        code: "fn valid(pair: (Check, Fix)) -> bool {\n    matches!(\n        pair,\n        (\n            Check::Line,\n            None | Some(Fix::Line),\n        )\n            | (Check::Ast, Some(Fix::Ast))\n            | (Check::Toml, Some(Fix::Toml))\n    )\n}",
        pass: false,
    },
    Example {
        label: "separated multiline match arms",
        code: "fn run(value: Value) {\n    match value {\n        Value::One => {\n            prepare_one();\n            one();\n            finish_one();\n        }\n\n        Value::Two => {\n            prepare_two();\n            two();\n            finish_two();\n        }\n    }\n}",
        pass: true,
    },
    Example {
        label: "adjacent multiline match arms",
        code: "fn run(value: Value) {\n    match value {\n        Value::One => {\n            prepare_one();\n            one();\n            finish_one();\n        }\n        Value::Two => {\n            prepare_two();\n            two();\n            finish_two();\n        }\n    }\n}",
        pass: false,
    },
    Example {
        label: "compact arms",
        code: "fn value(input: bool) -> usize { match input { true => 1, false => 0 } }",
        pass: true,
    },
];

crate::ast_rule!(
    match_layout,
    "Keep match arms and `matches!` patterns visually structured.",
    "Complex pattern alternatives and dense multiline arms hide control flow; explicit match arms and whitespace make cases scannable.",
    Low,
    params {
        checks: [String] = [
            "complex-matches",
            "multiline-arm-spacing",
        ] in [
            COMPLEX_MATCHES,
            MULTILINE_ARM_SPACING,
        ],
    },
);

fn check_match_layout(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let checks = ctx
        .file
        .config
        .get_str_array("rust_match_layout", &PARAMS[0]);
    let mut violations = Vec::new();

    if enabled(&checks, COMPLEX_MATCHES) {
        violations.extend(complex_matches_violations(ctx));
    }

    for expression in ctx.nodes::<ast::MatchExpr>() {
        let Some(arms) = expression.match_arm_list() else {
            continue;
        };
        let arms: Vec<ast::MatchArm> = arms.arms().collect();

        if enabled(&checks, MULTILINE_ARM_SPACING) {
            violations.extend(multiline_spacing_violations(ctx, &arms));
        }
    }

    violations
}

fn enabled(checks: &[String], check: &str) -> bool {
    checks.iter().any(|configured| configured == check)
}

fn complex_matches_violations(ctx: &AstCtx<'_>) -> Vec<Violation> {
    ctx.nodes::<ast::MacroCall>()
        .filter(is_matches_macro)
        .filter_map(|call| {
            let pattern = matches_pattern(&call)?;

            complex_pattern(&pattern).then(|| {
                ctx.violation(
                    &call,
                    "multiline `matches!` has complex alternatives — use a `match` expression with one readable case per arm",
                )
            })
        })
        .collect()
}

fn is_matches_macro(call: &ast::MacroCall) -> bool {
    call.path().is_some_and(|path| {
        matches!(
            path.syntax()
                .text()
                .to_string()
                .replace(char::is_whitespace, "")
                .as_str(),
            "matches" | "std::matches" | "core::matches"
        )
    })
}

fn matches_pattern(call: &ast::MacroCall) -> Option<String> {
    binary_macro_argument_candidates(call)?
        .into_iter()
        .find_map(|(expression, mut pattern)| {
            pattern.truncate(pattern.trim_end().len());

            if pattern.ends_with(',') {
                pattern.pop();
            }

            (parse_expression(&expression).is_some() && parse_pattern(&pattern).is_some())
                .then_some(pattern)
        })
}

fn parse_expression(source: &str) -> Option<ast::Expr> {
    let wrapper = format!("fn __rulewright_match_layout() {{ let _ = {source}; }}");
    let parse = SourceFile::parse(&wrapper, Edition::Edition2024);

    if !parse.errors().is_empty() {
        return None;
    }

    parse
        .tree()
        .syntax()
        .descendants()
        .find_map(ast::LetStmt::cast)?
        .initializer()
}

fn complex_pattern(source: &str) -> bool {
    if !source.contains('\n') {
        return false;
    }

    let Some(ast::Pat::OrPat(pattern)) = parse_pattern(source) else {
        return false;
    };

    let alternatives: Vec<ast::Pat> = pattern.pats().collect();

    alternatives.len() >= MIN_COMPLEX_ALTERNATIVES
        && alternatives.iter().any(|alternative| {
            matches!(alternative, ast::Pat::TuplePat(_))
                && alternative.syntax().text().contains_char('\n')
        })
}

fn parse_pattern(source: &str) -> Option<ast::Pat> {
    let wrapper =
        format!("fn __rulewright_match_layout(value: ()) {{ match value {{ {source} => () }} }}");
    let parse = SourceFile::parse(&wrapper, Edition::Edition2024);

    if !parse.errors().is_empty() {
        return None;
    }

    parse
        .tree()
        .syntax()
        .descendants()
        .find_map(ast::MatchArm::cast)?
        .pat()
}

fn multiline_spacing_violations(ctx: &AstCtx<'_>, arms: &[ast::MatchArm]) -> Vec<Violation> {
    arms.windows(2)
        .filter_map(|pair| {
            let [previous, current] = pair else {
                return None;
            };
            let multiline = is_substantial_arm(previous) && is_substantial_arm(current);

            (multiline && !has_blank_line_between(ctx, previous, current)).then(|| {
                ctx.violation(
                    current,
                    "blank line required between adjacent match arms with substantial multiline bodies",
                )
            })
        })
        .collect()
}

fn is_substantial_arm(arm: &ast::MatchArm) -> bool {
    let Some(expression) = arm.expr() else {
        return false;
    };

    if !expression.syntax().text().contains_char('\n') {
        return false;
    }

    match expression {
        ast::Expr::BlockExpr(block) => block
            .stmt_list()
            .is_some_and(|statements| statements.statements().count() >= MIN_COMPLEX_ALTERNATIVES),
        ast::Expr::MethodCallExpr(expression) => {
            expression
                .syntax()
                .descendants()
                .filter_map(ast::MethodCallExpr::cast)
                .count()
                >= MIN_COMPLEX_ALTERNATIVES
        }
        ast::Expr::CallExpr(expression) => expression
            .arg_list()
            .is_some_and(|arguments| arguments.args().count() >= MIN_COMPLEX_ALTERNATIVES),

        ast::Expr::RecordExpr(expression) => expression
            .record_expr_field_list()
            .is_some_and(|fields| fields.fields().count() >= MIN_COMPLEX_ALTERNATIVES),

        ast::Expr::ClosureExpr(expression) => expression
            .body()
            .is_some_and(|body| matches!(body, ast::Expr::BlockExpr(_))),
        _ => false,
    }
}

fn has_blank_line_between(
    ctx: &AstCtx<'_>,
    previous: &ast::MatchArm,
    current: &ast::MatchArm,
) -> bool {
    let start: usize = previous.syntax().text_range().end().into();
    let end: usize = current.syntax().text_range().start().into();
    let Some(gap) = ctx.file.contents.get(start..end) else {
        return false;
    };

    gap.contains("\n\n") || gap.contains("\r\n\r\n")
}

crate::rulewright_ast_test!(check_match_layout, {
    crate::example_tests!(EXAMPLES, check_match_layout);

    #[gtest]
    fn two_multiline_matches_alternatives_remain_readable() -> Result<()> {
        let source = "fn valid(value: Value) -> bool {\n    matches!(\n        value,\n        Value::One\n            | Value::Two\n    )\n}";

        verify_true!(run(source).is_empty())
    }

    #[gtest]
    fn nested_pipes_do_not_count_as_top_level_alternatives() -> Result<()> {
        let source = "fn valid(value: Option<Value>) -> bool {\n    matches!(\n        value,\n        Some(Value::One | Value::Two | Value::Three)\n    )\n}";

        verify_true!(run(source).is_empty())
    }

    #[gtest]
    fn tuple_alternatives_remain_readable_when_each_stays_on_one_line() -> Result<()> {
        let source = "fn valid(pair: (Check, Fix)) -> bool {\n    matches!(\n        pair,\n        (Check::Line, Fix::Line)\n            | (Check::Ast, Fix::Ast)\n            | (Check::Toml, Fix::Toml)\n    )\n}";

        verify_true!(run(source).is_empty())
    }

    #[gtest]
    fn standard_qualified_matches_macros_are_checked() -> Result<()> {
        for macro_path in ["matches", "std::matches", "core::matches"] {
            let source = format!(
                "fn valid(pair: (Check, Fix)) -> bool {{\n    {macro_path}!(\n        pair,\n        (\n            Check::Line,\n            Fix::Line,\n        )\n            | (Check::Ast, Fix::Ast)\n            | (Check::Toml, Fix::Toml)\n    )\n}}"
            );

            verify_eq!(run(&source).len(), 1)?;
        }

        Ok(())
    }

    #[gtest]
    fn trailing_pattern_commas_are_checked_for_every_standard_macro_path() -> Result<()> {
        for macro_path in ["matches", "std::matches", "core::matches"] {
            let source = format!(
                "fn valid(pair: (Check, Fix)) -> bool {{\n    {macro_path}!(\n        pair,\n        (\n            Check::Line,\n            Fix::Line,\n        )\n            | (Check::Ast, Fix::Ast)\n            | (Check::Toml, Fix::Toml),\n    )\n}}"
            );

            verify_eq!(run(&source).len(), 1)?;
        }

        Ok(())
    }

    #[gtest]
    fn generic_commas_do_not_hide_the_pattern_argument() -> Result<()> {
        let source = "fn valid() -> bool {\n    matches!(\n        build::<Alpha, Beta>(),\n        (\n            Value::One,\n            Value::Two,\n        )\n            | (Value::Three, Value::Four)\n            | (Value::Five, Value::Six)\n    )\n}";

        verify_eq!(run(source).len(), 1)
    }

    #[gtest]
    fn const_generics_and_pattern_guards_keep_the_correct_split() -> Result<()> {
        let source = "fn valid() -> bool {\n    matches!(\n        build::<{ 1 }, { 2 }>(),\n        (\n            Value::One,\n            Value::Two,\n        )\n            | (Value::Three, Value::Four)\n            | (Value::Five, Value::Six) if enabled()\n    )\n}";

        verify_eq!(run(source).len(), 1)
    }

    #[gtest]
    fn adjacent_substantial_expression_arms_require_spacing() -> Result<()> {
        let source = "fn value(input: Input) -> usize {\n    match input {\n        Input::One(value) => value\n            .normalize()\n            .as_slice()\n            .len(),\n        Input::Two(value) => value\n            .normalize()\n            .as_slice()\n            .len(),\n    }\n}";

        verify_eq!(run(source).len(), 1)
    }

    #[gtest]
    fn checks_can_be_enabled_independently() -> Result<()> {
        let source = "fn valid(pair: (Check, Fix), value: Value) -> bool {\n    let compatible = matches!(\n        pair,\n        (\n            Check::Line,\n            Fix::Line,\n        )\n            | (Check::Ast, Fix::Ast)\n            | (Check::Toml, Fix::Toml)\n    );\n    match value {\n        Value::One => {\n            prepare_one();\n            run_one();\n            finish_one();\n        }\n        Value::Two => {\n            prepare_two();\n            run_two();\n            finish_two();\n        }\n    }\n    compatible\n}";
        let complex = crate::test_support::check_source_ast_params(
            source,
            "rust_match_layout",
            &[("checks", &[COMPLEX_MATCHES])],
            check_match_layout,
        );
        let spacing = crate::test_support::check_source_ast_params(
            source,
            "rust_match_layout",
            &[("checks", &[MULTILINE_ARM_SPACING])],
            check_match_layout,
        );
        let disabled = crate::test_support::check_source_ast_params(
            source,
            "rust_match_layout",
            &[("checks", &[])],
            check_match_layout,
        );

        verify_eq!(complex.len(), 1)?;
        verify_eq!(spacing.len(), 1)?;
        verify_true!(disabled.is_empty())
    }
});