rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
use line_index::LineIndex;
use ra_ap_syntax::{
    AstNode, Edition, SourceFile, SyntaxKind,
    ast::{self, HasName, HasVisibility, VisibilityKind},
};

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

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "fixed-name pub struct in quote body",
        code: "fn expand() {\n    let _ = quote::quote! { pub struct Generated; };\n}",
        pass: false,
    },
    Example {
        label: "fixed-name pub fn in quote body",
        code: "fn expand() {\n    let _ = quote::quote! { pub fn helper() {} };\n}",
        pass: false,
    },
    Example {
        label: "fixed-name pub enum in quote body",
        code: "fn expand() {\n    let _ = quote::quote! { pub enum Kind {} };\n}",
        pass: false,
    },
    Example {
        label: "fixed-name pub trait in quote body",
        code: "fn expand() {\n    let _ = quote::quote! { pub trait Ext {} };\n}",
        pass: false,
    },
    Example {
        label: "interpolated ident passes",
        code: "fn expand() {\n    let _ = quote::quote! { pub struct #name; };\n}",
        pass: true,
    },
    Example {
        label: "repeated interpolation passes",
        code: "fn expand() {\n    let _ = quote::quote! { #(pub fn #getters();)* };\n}",
        pass: true,
    },
    Example {
        label: "private emitted item passes",
        code: "fn expand() {\n    let _ = quote::quote! { struct Hidden; };\n}",
        pass: true,
    },
    Example {
        label: "pub item outside quote body",
        code: "pub struct Normal;",
        pass: true,
    },
    Example {
        label: "opener in comment",
        code: "// quote! { pub struct Generated; }",
        pass: true,
    },
];

crate::ast_rule!(
    macro_hidden_items,
    "Flag fixed-name `pub` items emitted from quote! bodies.",
    "Fixed-name emitted items collide across expansions and clash with user code — interpolate the identifier instead.",
    Medium,
);

fn check_macro_hidden_items(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let quote_trees = ctx
        .nodes::<ast::MacroCall>()
        .filter(|call| !ctx.is_in_test(call) && is_quote_call(call))
        .filter_map(|call| call.token_tree());

    quote_trees
        .flat_map(|tree| emitted_public_items(ctx, &tree))
        .collect()
}

fn emitted_public_items(ctx: &AstCtx<'_>, tree: &ast::TokenTree) -> Vec<Violation> {
    let Some((source, body_line)) = token_tree_body(ctx, tree) else {
        return Vec::new();
    };
    let parse = SourceFile::parse(&source, Edition::Edition2024);
    let root = parse.tree();
    let line_index = LineIndex::new(&source);

    root.syntax()
        .descendants()
        .filter_map(public_item)
        .map(|(item, ident)| {
            let relative_line = line_index.line_col(item.text_range().start()).line as usize;

            crate::violation(
                ctx.file.rel,
                body_line + relative_line,
                format!(
                    "macro emits fixed-name public item `{ident}` — fixed names collide \
                     across expansions, interpolate the identifier"
                ),
            )
        })
        .collect()
}

fn public_item(node: ra_ap_syntax::SyntaxNode) -> Option<(ra_ap_syntax::SyntaxNode, String)> {
    if !matches!(
        node.kind(),
        SyntaxKind::STRUCT | SyntaxKind::ENUM | SyntaxKind::TRAIT | SyntaxKind::FN
    ) {
        return None;
    }

    let visibility = ast::AnyHasVisibility::cast(node.clone())?.visibility()?;

    if !matches!(visibility.kind(), VisibilityKind::Pub) {
        return None;
    }

    let name = ast::AnyHasName::cast(node.clone())?
        .name()?
        .text()
        .to_string();

    Some((node, name))
}

fn token_tree_body(ctx: &AstCtx<'_>, tree: &ast::TokenTree) -> Option<(String, usize)> {
    let opener = tree
        .l_paren_token()
        .or_else(|| tree.l_brack_token())
        .or_else(|| tree.l_curly_token())?;
    let closer = tree
        .r_paren_token()
        .or_else(|| tree.r_brack_token())
        .or_else(|| tree.r_curly_token())?;
    let start = u32::from(opener.text_range().end()) as usize;
    let end = u32::from(closer.text_range().start()) as usize;
    let source = ctx.file.contents.get(start..end).map(str::to_owned)?;
    let body_line = ctx.line_index.line_col(opener.text_range().start()).line as usize + 1;

    Some((source, body_line))
}

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