|
| 1 | +use crate::checkers::ast::Checker; |
| 2 | +use ruff_diagnostics::{AlwaysFixableViolation, Applicability, Diagnostic, Edit, Fix}; |
| 3 | +use ruff_macros::{derive_message_formats, ViolationMetadata}; |
| 4 | +use ruff_python_ast::{Arguments, Expr, ExprCall, ExprName, ExprNumberLiteral, Number}; |
| 5 | +use ruff_python_semantic::analyze::typing; |
| 6 | +use ruff_python_semantic::SemanticModel; |
| 7 | +use ruff_text_size::TextRange; |
| 8 | + |
| 9 | +/// ## What it does |
| 10 | +/// Checks for `int` conversions of values that are already integers. |
| 11 | +/// |
| 12 | +/// ## Why is this bad? |
| 13 | +/// Such a conversion is unnecessary. |
| 14 | +/// |
| 15 | +/// ## Known problems |
| 16 | +/// This rule may produce false positives for `round`, `math.ceil`, `math.floor`, |
| 17 | +/// and `math.trunc` calls when values override the `__round__`, `__ceil__`, `__floor__`, |
| 18 | +/// or `__trunc__` operators such that they don't return an integer. |
| 19 | +/// |
| 20 | +/// ## Example |
| 21 | +/// |
| 22 | +/// ```python |
| 23 | +/// int(len([])) |
| 24 | +/// int(round(foo, None)) |
| 25 | +/// ``` |
| 26 | +/// |
| 27 | +/// Use instead: |
| 28 | +/// |
| 29 | +/// ```python |
| 30 | +/// len([]) |
| 31 | +/// round(foo) |
| 32 | +/// ``` |
| 33 | +/// |
| 34 | +/// ## Fix safety |
| 35 | +/// The fix for `round`, `math.ceil`, `math.floor`, and `math.truncate` is unsafe |
| 36 | +/// because removing the `int` conversion can change the semantics for values |
| 37 | +/// overriding the `__round__`, `__ceil__`, `__floor__`, or `__trunc__` dunder methods |
| 38 | +/// such that they don't return an integer. |
| 39 | +#[derive(ViolationMetadata)] |
| 40 | +pub(crate) struct UnnecessaryCastToInt; |
| 41 | + |
| 42 | +impl AlwaysFixableViolation for UnnecessaryCastToInt { |
| 43 | + #[derive_message_formats] |
| 44 | + fn message(&self) -> String { |
| 45 | + "Value being casted is already an integer".to_string() |
| 46 | + } |
| 47 | + |
| 48 | + fn fix_title(&self) -> String { |
| 49 | + "Remove unnecessary conversion to `int`".to_string() |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +/// RUF046 |
| 54 | +pub(crate) fn unnecessary_cast_to_int(checker: &mut Checker, call: &ExprCall) { |
| 55 | + let semantic = checker.semantic(); |
| 56 | + |
| 57 | + let Some(Expr::Call(inner_call)) = single_argument_to_int_call(semantic, call) else { |
| 58 | + return; |
| 59 | + }; |
| 60 | + |
| 61 | + let (func, arguments) = (&inner_call.func, &inner_call.arguments); |
| 62 | + let (outer_range, inner_range) = (call.range, inner_call.range); |
| 63 | + |
| 64 | + let Some(qualified_name) = checker.semantic().resolve_qualified_name(func) else { |
| 65 | + return; |
| 66 | + }; |
| 67 | + |
| 68 | + let fix = match qualified_name.segments() { |
| 69 | + // Always returns a strict instance of `int` |
| 70 | + ["" | "builtins", "len" | "id" | "hash" | "ord" | "int"] |
| 71 | + | ["math", "comb" | "factorial" | "gcd" | "lcm" | "isqrt" | "perm"] => { |
| 72 | + Fix::safe_edit(replace_with_inner(checker, outer_range, inner_range)) |
| 73 | + } |
| 74 | + |
| 75 | + // Depends on `ndigits` and `number.__round__` |
| 76 | + ["" | "builtins", "round"] => { |
| 77 | + if let Some(fix) = replace_with_shortened_round_call(checker, outer_range, arguments) { |
| 78 | + fix |
| 79 | + } else { |
| 80 | + return; |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + // Depends on `__ceil__`/`__floor__`/`__trunc__` |
| 85 | + ["math", "ceil" | "floor" | "trunc"] => { |
| 86 | + Fix::unsafe_edit(replace_with_inner(checker, outer_range, inner_range)) |
| 87 | + } |
| 88 | + |
| 89 | + _ => return, |
| 90 | + }; |
| 91 | + |
| 92 | + checker |
| 93 | + .diagnostics |
| 94 | + .push(Diagnostic::new(UnnecessaryCastToInt, call.range).with_fix(fix)); |
| 95 | +} |
| 96 | + |
| 97 | +fn single_argument_to_int_call<'a>( |
| 98 | + semantic: &SemanticModel, |
| 99 | + call: &'a ExprCall, |
| 100 | +) -> Option<&'a Expr> { |
| 101 | + let ExprCall { |
| 102 | + func, arguments, .. |
| 103 | + } = call; |
| 104 | + |
| 105 | + if !semantic.match_builtin_expr(func, "int") { |
| 106 | + return None; |
| 107 | + } |
| 108 | + |
| 109 | + if !arguments.keywords.is_empty() { |
| 110 | + return None; |
| 111 | + } |
| 112 | + |
| 113 | + let [argument] = &*arguments.args else { |
| 114 | + return None; |
| 115 | + }; |
| 116 | + |
| 117 | + Some(argument) |
| 118 | +} |
| 119 | + |
| 120 | +/// Returns an [`Edit`] when the call is of any of the forms: |
| 121 | +/// * `round(integer)`, `round(integer, 0)`, `round(integer, None)` |
| 122 | +/// * `round(whatever)`, `round(whatever, None)` |
| 123 | +fn replace_with_shortened_round_call( |
| 124 | + checker: &Checker, |
| 125 | + outer_range: TextRange, |
| 126 | + arguments: &Arguments, |
| 127 | +) -> Option<Fix> { |
| 128 | + if arguments.len() > 2 { |
| 129 | + return None; |
| 130 | + } |
| 131 | + |
| 132 | + let number = arguments.find_argument("number", 0)?; |
| 133 | + let ndigits = arguments.find_argument("ndigits", 1); |
| 134 | + |
| 135 | + let number_is_int = match number { |
| 136 | + Expr::Name(name) => is_int(checker.semantic(), name), |
| 137 | + Expr::NumberLiteral(ExprNumberLiteral { value, .. }) => matches!(value, Number::Int(..)), |
| 138 | + _ => false, |
| 139 | + }; |
| 140 | + |
| 141 | + match ndigits { |
| 142 | + Some(Expr::NumberLiteral(ExprNumberLiteral { value, .. })) |
| 143 | + if is_literal_zero(value) && number_is_int => {} |
| 144 | + Some(Expr::NoneLiteral(_)) | None => {} |
| 145 | + _ => return None, |
| 146 | + }; |
| 147 | + |
| 148 | + let number_expr = checker.locator().slice(number); |
| 149 | + let new_content = format!("round({number_expr})"); |
| 150 | + |
| 151 | + let applicability = if number_is_int { |
| 152 | + Applicability::Safe |
| 153 | + } else { |
| 154 | + Applicability::Unsafe |
| 155 | + }; |
| 156 | + |
| 157 | + Some(Fix::applicable_edit( |
| 158 | + Edit::range_replacement(new_content, outer_range), |
| 159 | + applicability, |
| 160 | + )) |
| 161 | +} |
| 162 | + |
| 163 | +fn is_int(semantic: &SemanticModel, name: &ExprName) -> bool { |
| 164 | + let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else { |
| 165 | + return false; |
| 166 | + }; |
| 167 | + |
| 168 | + typing::is_int(binding, semantic) |
| 169 | +} |
| 170 | + |
| 171 | +fn is_literal_zero(value: &Number) -> bool { |
| 172 | + let Number::Int(int) = value else { |
| 173 | + return false; |
| 174 | + }; |
| 175 | + |
| 176 | + matches!(int.as_u8(), Some(0)) |
| 177 | +} |
| 178 | + |
| 179 | +fn replace_with_inner(checker: &Checker, outer_range: TextRange, inner_range: TextRange) -> Edit { |
| 180 | + let inner_expr = checker.locator().slice(inner_range); |
| 181 | + |
| 182 | + Edit::range_replacement(inner_expr.to_string(), outer_range) |
| 183 | +} |
0 commit comments