rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::{AstNode, Edition, SourceFile, ast, syntax_editor::SyntaxEditor};

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

const ADJACENT_PAIR_LEN: usize = 2;

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "sorted derives",
        code: "#[derive(Clone, Debug, thiserror::Error)]\nstruct Item;",
        pass: true,
    },
    Example {
        label: "unsorted derives",
        code: "#[derive(Debug, Clone)]\nstruct Item;",
        pass: false,
    },
    Example {
        label: "qualified paths use full text",
        code: "#[derive(thiserror::Error, Clone, Debug)]\nenum Failure {}",
        pass: false,
    },
    Example {
        label: "non-derive attribute",
        code: "#[cfg(test)]\nstruct Item;",
        pass: true,
    },
];

crate::ast_tree_rule!(
    derive_order,
    "Require traits inside derive attributes to be sorted alphabetically.",
    "Stable derive ordering keeps attribute diffs deterministic and makes duplicated traits easy to spot.",
    Low,
    fix_derive_order,
);

fn check_derive_order(ctx: &AstCtx<'_>) -> Vec<Violation> {
    ctx.nodes::<ast::Attr>()
        .filter_map(|attr| {
            let entries = derive_entries(&attr)?;

            (!is_sorted(&entries)).then(|| {
                ctx.violation(
                    &attr,
                    "derive traits are not alphabetically sorted (case-sensitive)",
                )
            })
        })
        .collect()
}

fn derive_entries(attr: &ast::Attr) -> Option<Vec<String>> {
    let (name, tokens) = attr.as_simple_call()?;

    if name != "derive" {
        return None;
    }

    let text = tokens.syntax().text().to_string();
    let body = text.strip_prefix('(')?.strip_suffix(')')?;

    Some(
        body.split(',')
            .map(str::trim)
            .filter(|entry| !entry.is_empty())
            .map(str::to_owned)
            .collect(),
    )
}

fn is_sorted(entries: &[String]) -> bool {
    entries
        .windows(ADJACENT_PAIR_LEN)
        .all(|pair| pair[0] <= pair[1])
}

fn fix_derive_order(ctx: &AstCtx<'_>, _violations: &[Violation]) -> Option<String> {
    let (editor, root) = SyntaxEditor::with_ast_node(ctx.root);
    let mut changed = false;

    for attr in root.syntax().descendants().filter_map(ast::Attr::cast) {
        let Some(mut entries) = derive_entries(&attr) else {
            continue;
        };

        if is_sorted(&entries) {
            continue;
        }

        entries.sort();
        let replacement = parse_attr(&format!("#[derive({})]", entries.join(", ")))?;

        editor.replace(attr.syntax().clone(), replacement.syntax().clone());
        changed = true;
    }

    changed.then(|| editor.finish().new_root().to_string())
}

fn parse_attr(source: &str) -> Option<ast::Attr> {
    let fixture = format!("{source}\nstruct Fixture;");
    let parse = SourceFile::parse(&fixture, Edition::Edition2024);

    parse
        .errors()
        .is_empty()
        .then(|| parse.tree())?
        .syntax()
        .descendants()
        .find_map(ast::Attr::cast)
}

crate::rulewright_ast_test!(check_derive_order, {
    crate::example_tests!(EXAMPLES, check_derive_order);
    crate::fix_tests!(EXAMPLES, ast_tree, check_derive_order, fix_derive_order);
});