rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::{
    AstNode,
    ast::{self, HasAttrs},
};

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

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "cast to u8",
        code: "fn f() { let x = 42u64 as u8; }",
        pass: false,
    },
    Example {
        label: "cast to f32",
        code: "fn f() { let x = 1.0f64 as f32; }",
        pass: false,
    },
    Example {
        label: "cast to i16",
        code: "fn f() { let x = 1000i32 as i16; }",
        pass: false,
    },
    Example {
        label: "cast to u32",
        code: "fn f() { let x = 42u64 as u32; }",
        pass: true,
    },
    Example {
        label: "cast to u64",
        code: "fn f() { let x = 42u32 as u64; }",
        pass: true,
    },
    Example {
        label: "cast to usize",
        code: "fn f() { let x = 42u32 as usize; }",
        pass: true,
    },
    Example {
        label: "cast in test module",
        code: "#[cfg(test)]\nmod tests {\n    fn f() { let x = 42u64 as u8; }\n}",
        pass: true,
    },
    Example {
        label: "documented conversion policy helper",
        code: "#[expect(clippy::cast_possible_truncation, reason = \"wire payload deliberately stores f32\")]\nfn payload(value: f64) -> f32 { value as f32 }",
        pass: true,
    },
];

crate::ast_rule!(
    lossy_cast,
    "Flag `as` casts to types that lose precision (`f32`, `u8`, `u16`, `i8`, `i16`).",
    "Casting to a smaller integer silently truncates, so use `TryFrom` or byte-level extraction. Float narrowing has no fallible standard conversion: centralize it in a named policy helper with bounds or rounding behavior, and document the deliberate Clippy cast expectation there instead of scattering casts.",
    Medium,
);

const LOSSY_TARGETS: &[&str] = &["f32", "u8", "u16", "i8", "i16"];

fn check_lossy_cast(ctx: &AstCtx<'_>) -> Vec<Violation> {
    ctx.nodes::<ast::CastExpr>()
        .filter(|cast| !ctx.is_in_test(cast) && !has_documented_cast_policy(cast))
        .filter_map(|cast| {
            let ast::Type::PathType(path) = cast.ty()? else {
                return None;
            };
            let ident = path.path()?.segment()?.name_ref()?;

            LOSSY_TARGETS
                .contains(&ident.text().as_str())
                .then(|| {
                    ctx.violation(
                        &cast,
                        format!(
                            "potentially lossy cast to `{ident}` — use explicit conversion (e.g. try_into(), try_from())"
                        ),
                    )
                })
        })
        .collect()
}

fn has_documented_cast_policy(cast: &ast::CastExpr) -> bool {
    cast.syntax()
        .ancestors()
        .find_map(ast::Fn::cast)
        .is_some_and(|function| {
            function.attrs().any(|attribute| {
                let source = attribute.syntax().text().to_string();

                source.contains("expect(")
                    && source.contains("reason")
                    && (source.contains("clippy::cast_possible_truncation")
                        || source.contains("clippy::cast_precision_loss")
                        || source.contains("clippy::cast_sign_loss"))
            })
        })
}

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