rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::{
    AstNode,
    ast::{self, HasAttrs, HasName, HasVisibility, VisibilityKind},
};
use std::collections::HashSet;

use super::support::type_name;
use crate::{AstCtx, Example, Violation};

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "error struct without traits",
        code: "pub struct ParseError { line: usize }",
        pass: false,
    },
    Example {
        label: "error struct with Display only",
        code: "pub struct ParseError;\nimpl std::fmt::Display for ParseError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.write_str(\"parse error\")\n    }\n}",
        pass: false,
    },
    Example {
        label: "error struct with Display and Error",
        code: "pub struct ParseError;\nimpl std::fmt::Display for ParseError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.write_str(\"parse error\")\n    }\n}\nimpl std::error::Error for ParseError {}",
        pass: true,
    },
    Example {
        label: "thiserror derive on struct",
        code: "#[derive(Debug, thiserror::Error)]\n#[error(\"parse failed\")]\npub struct ParseError;",
        pass: true,
    },
    Example {
        label: "thiserror derive on enum",
        code: "#[derive(Debug, Error)]\npub enum FetchError {\n    #[error(\"io failed\")]\n    Io,\n}",
        pass: true,
    },
    Example {
        label: "private error struct",
        code: "struct ParseError;",
        pass: true,
    },
    Example {
        label: "pub struct without error suffix",
        code: "pub struct Parser { pos: usize }",
        pass: true,
    },
    Example {
        label: "error struct in test module",
        code: "#[cfg(test)]\nmod tests {\n    pub struct ParseError;\n}",
        pass: true,
    },
];

crate::ast_rule!(
    error_missing_traits,
    "Require `Display` and `std::error::Error` on public `*Error` types.",
    "std::error::Error mandates Display, and error types without both cannot participate in ?-chains, error reporting, or dyn Error composition.",
    Medium,
);

fn check_error_missing_traits(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let (display_impls, error_impls) = collect_trait_impls(ctx);
    let structs = ctx
        .nodes::<ast::Struct>()
        .flat_map(|item| check_error_item(ctx, &item, false, &display_impls, &error_impls));
    let enums = ctx.nodes::<ast::Enum>().flat_map(|item| {
        let variant_error_attrs = item
            .variant_list()
            .is_some_and(|variants| variants.variants().any(|variant| has_error_attr(&variant)));

        check_error_item(
            ctx,
            &item,
            variant_error_attrs,
            &display_impls,
            &error_impls,
        )
    });

    structs.chain(enums).collect()
}

fn collect_trait_impls(ctx: &AstCtx<'_>) -> (HashSet<String>, HashSet<String>) {
    let mut display_impls = HashSet::default();
    let mut error_impls = HashSet::default();

    for item in ctx.nodes::<ast::Impl>() {
        let Some(trait_name) = item.trait_().and_then(|ty| type_name(&ty)) else {
            continue;
        };
        let Some(type_name) = item.self_ty().and_then(|ty| type_name(&ty)) else {
            continue;
        };

        if trait_name == "Display" {
            display_impls.insert(type_name);
        } else if trait_name == "Error" {
            error_impls.insert(type_name);
        }
    }

    (display_impls, error_impls)
}

fn has_error_derive(item: &impl HasAttrs) -> bool {
    item.attrs().any(|attr| {
        attr.as_simple_call().is_some_and(|(name, tokens)| {
            name == "derive"
                && tokens.syntax().text().to_string().split(',').any(|entry| {
                    entry
                        .trim_matches(|ch: char| ch.is_whitespace() || matches!(ch, '(' | ')'))
                        .rsplit("::")
                        .next()
                        == Some("Error")
                })
        })
    })
}

fn has_error_attr(item: &impl HasAttrs) -> bool {
    item.attrs()
        .any(|attr| attr.simple_name().as_deref() == Some("error"))
}

fn check_error_item<T>(
    ctx: &AstCtx<'_>,
    item: &T,
    variant_error_attrs: bool,
    display_impls: &HashSet<String>,
    error_impls: &HashSet<String>,
) -> Vec<Violation>
where
    T: AstNode + HasAttrs + HasName + HasVisibility,
{
    let Some(name_node) = item.name() else {
        return Vec::new();
    };
    let name = name_node.text().to_string();

    if ctx.is_in_test(item)
        || !item
            .visibility()
            .is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub))
        || !name.ends_with("Error")
    {
        return Vec::new();
    }

    let derive_error = has_error_derive(item);
    let display_ok = derive_error
        || variant_error_attrs
        || has_error_attr(item)
        || display_impls.contains(&name);
    let error_ok = derive_error || error_impls.contains(&name);
    let mut out = Vec::new();

    if !display_ok {
        out.push(ctx.violation(
            &name_node,
            format!("public error type `{name}` must implement `Display`"),
        ));
    }

    if !error_ok {
        out.push(ctx.violation(
            &name_node,
            format!("public error type `{name}` must implement `std::error::Error`"),
        ));
    }

    out
}

crate::rulewright_ast_test!(check_error_missing_traits, {
    crate::example_tests!(EXAMPLES, check_error_missing_traits);
});