rulewright 0.1.2

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

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

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "Vec::new filled from xs.iter()",
        code: "fn f(xs: &[u32]) { let mut out = Vec::new(); for x in xs.iter() { out.push(*x); } }",
        pass: false,
    },
    Example {
        label: "HashMap::new filled from &v",
        code: "fn f(v: Vec<u32>) { let mut m = std::collections::HashMap::new(); for x in &v { m.insert(*x, 1); } }",
        pass: false,
    },
    Example {
        label: "String::new filled over a range",
        code: "fn f(n: usize) { let mut s = String::new(); for _ in 0..n { s.push_str(\"x\"); } }",
        pass: false,
    },
    Example {
        label: "annotated Default::default() filled in sized loop",
        code: "fn f(xs: &[u32]) { let mut out: Vec<u32> = Default::default(); for x in xs.iter() { out.push(*x); } }",
        pass: false,
    },
    Example {
        label: "conditionally sparse output does not imply source-sized capacity",
        code: "fn f(xs: &[u32]) { let mut out = Vec::new(); for x in xs.iter() { if *x > 0 { out.push(*x); } } }",
        pass: true,
    },
    Example {
        label: "match-selected output does not imply source-sized capacity",
        code: "fn f(xs: &[Option<u32>]) { let mut out = Vec::new(); for x in xs.iter() { if let Some(x) = x { out.push(*x); } } }",
        pass: true,
    },
    Example {
        label: "with_capacity already used",
        code: "fn f(xs: &[u32]) { let mut out = Vec::with_capacity(xs.len()); for x in xs.iter() { out.push(*x); } }",
        pass: true,
    },
    Example {
        label: "consecutive pushes belong to rust_vec_init_then_push, not this rule",
        code: "fn f() { let mut v = Vec::new(); v.push(1); v.push(2); }",
        pass: true,
    },
    Example {
        label: "adapter chain has no knowable length",
        code: "fn f(xs: &[u32]) { let mut out = Vec::new(); for x in xs.iter().filter(|x| **x > 0) { out.push(**x); } }",
        pass: true,
    },
    Example {
        label: "collect already sizes via size_hint",
        code: "fn f(xs: &[u32]) -> Vec<u32> { xs.iter().map(|x| x + 1).collect() }",
        pass: true,
    },
    Example {
        label: "sized fill loop in test module",
        code: "#[cfg(test)]\nmod tests {\n    fn t(xs: &[u32]) { let mut out = Vec::new(); for x in xs.iter() { out.push(*x); } }\n}",
        pass: true,
    },
];

crate::ast_rule!(
    missing_capacity,
    "Flag collections built with `new()`/`default()` then unconditionally grown inside a loop over a sized source.",
    "When every source item grows the collection, the final size is knowable and with_capacity or collect avoids repeated reallocation. Conditional output may be sparse and is deliberately excluded.",
);

fn check_missing_capacity(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let mut violations = Vec::new();

    for block in ctx.nodes::<ast::StmtList>() {
        if ctx.is_in_test(&block) {
            continue;
        }

        let entries: Vec<ra_ap_syntax::SyntaxNode> = block.syntax().children().collect();

        for (index, entry) in entries.iter().enumerate() {
            let Some(local) = ast::LetStmt::cast(entry.clone()) else {
                continue;
            };
            let Some(name) = collection_binding(&local) else {
                continue;
            };

            if grown_in_sized_loop(entries.iter().skip(index + 1), &name) {
                violations.push(ctx.violation(&local, MSG));
            }
        }
    }

    violations
}

const SIZED_COLLECTIONS: &[&str] = &["HashMap", "HashSet", "String", "Vec"];
const GROW_METHODS: &[&str] = &["insert", "push", "push_str"];

const MSG: &str = "collection built without capacity then grown in a loop over a sized source — use with_capacity(...) or collect()";

fn collection_binding(local: &ast::LetStmt) -> Option<String> {
    let ast::Pat::IdentPat(pattern) = local.pat()? else {
        return None;
    };

    pattern.mut_token()?;
    let name = pattern.name()?.text().to_string();
    let initializer = local.initializer()?;

    is_empty_ctor(&initializer, local.ty().as_ref()).then_some(name)
}

fn is_empty_ctor(expr: &ast::Expr, annotated: Option<&ast::Type>) -> bool {
    let ast::Expr::CallExpr(call) = expr else {
        return false;
    };

    if !support::has_no_args(call) {
        return false;
    }

    let Some(ast::Expr::PathExpr(function)) = call.expr() else {
        return false;
    };
    let Some(path) = function.path() else {
        return false;
    };
    let names = support::path_names(&path);
    let Some(last) = names.last() else {
        return false;
    };

    if last == "new" {
        return names
            .iter()
            .rev()
            .nth(1)
            .is_some_and(|ty| SIZED_COLLECTIONS.contains(&ty.as_str()));
    }

    if last == "default" {
        return annotated
            .and_then(type_name)
            .is_some_and(|name| SIZED_COLLECTIONS.contains(&name.as_str()));
    }

    false
}

fn sized_source(expr: &ast::Expr) -> bool {
    match expr {
        ast::Expr::RefExpr(_) | ast::Expr::RangeExpr(_) => true,
        ast::Expr::MethodCallExpr(call) => support::method_name(call).as_deref() == Some("iter"),
        _ => false,
    }
}

fn grown_in_sized_loop<'a>(
    rest: impl Iterator<Item = &'a ra_ap_syntax::SyntaxNode>,
    name: &str,
) -> bool {
    for entry in rest {
        let loop_expr = ast::ForExpr::cast(entry.clone()).or_else(|| {
            ast::ExprStmt::cast(entry.clone()).and_then(|statement| match statement.expr()? {
                ast::Expr::ForExpr(loop_expr) => Some(loop_expr),
                _ => None,
            })
        });
        let Some(loop_expr) = loop_expr else {
            continue;
        };

        if !support::loop_source(&loop_expr).is_some_and(|source| sized_source(&source)) {
            continue;
        }

        let Some(body) = support::loop_body(&loop_expr) else {
            continue;
        };

        if body
            .syntax()
            .descendants()
            .filter_map(ast::MethodCallExpr::cast)
            .any(|call| grows_collection(&call, name) && is_unconditional_growth(&call, &body))
        {
            return true;
        }
    }

    false
}

fn is_unconditional_growth(call: &ast::MethodCallExpr, loop_body: &ast::BlockExpr) -> bool {
    for ancestor in call.syntax().ancestors().skip(1) {
        if &ancestor == loop_body.syntax() {
            return true;
        }

        if ast::IfExpr::can_cast(ancestor.kind())
            || ast::MatchExpr::can_cast(ancestor.kind())
            || ast::MatchArm::can_cast(ancestor.kind())
            || ast::ClosureExpr::can_cast(ancestor.kind())
            || ast::ForExpr::can_cast(ancestor.kind())
            || ast::WhileExpr::can_cast(ancestor.kind())
            || ast::LoopExpr::can_cast(ancestor.kind())
        {
            return false;
        }
    }

    false
}

fn grows_collection(call: &ast::MethodCallExpr, name: &str) -> bool {
    if support::method_name(call).is_none_or(|method| !GROW_METHODS.contains(&method.as_str())) {
        return false;
    }

    call.receiver()
        .and_then(|receiver| support::path_expr_name(&receiver))
        .is_some_and(|receiver| receiver == name)
}

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