Skip to content

Commit fda8b1f

Browse files
[ruff] Unnecessary cast to int (RUF046) (#14697)
## Summary Resolves #11412. ## Test Plan `cargo nextest run` and `cargo insta test`. --------- Co-authored-by: Micha Reiser <[email protected]>
1 parent 2d3f557 commit fda8b1f

File tree

9 files changed

+672
-1
lines changed

9 files changed

+672
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import math
2+
3+
4+
### Safely fixable
5+
6+
# Arguments are not checked
7+
int(id())
8+
int(len([]))
9+
int(ord(foo))
10+
int(hash(foo, bar))
11+
int(int(''))
12+
13+
int(math.comb())
14+
int(math.factorial())
15+
int(math.gcd())
16+
int(math.lcm())
17+
int(math.isqrt())
18+
int(math.perm())
19+
20+
21+
### Unsafe
22+
23+
int(math.ceil())
24+
int(math.floor())
25+
int(math.trunc())
26+
27+
28+
### `round()`
29+
30+
## Errors
31+
int(round(0))
32+
int(round(0, 0))
33+
int(round(0, None))
34+
35+
int(round(0.1))
36+
int(round(0.1, None))
37+
38+
# Argument type is not checked
39+
foo = type("Foo", (), {"__round__": lambda self: 4.2})()
40+
41+
int(round(foo))
42+
int(round(foo, 0))
43+
int(round(foo, None))
44+
45+
## No errors
46+
int(round(0, 3.14))
47+
int(round(0, non_literal))
48+
int(round(0, 0), base)
49+
int(round(0, 0, extra=keyword))
50+
int(round(0.1, 0))

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

+3
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
10931093
if checker.enabled(Rule::Airflow3Removal) {
10941094
airflow::rules::removed_in_3(checker, expr);
10951095
}
1096+
if checker.enabled(Rule::UnnecessaryCastToInt) {
1097+
ruff::rules::unnecessary_cast_to_int(checker, call);
1098+
}
10961099
}
10971100
Expr::Dict(dict) => {
10981101
if checker.any_enabled(&[

crates/ruff_linter/src/codes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
983983
(Ruff, "039") => (RuleGroup::Preview, rules::ruff::rules::UnrawRePattern),
984984
(Ruff, "040") => (RuleGroup::Preview, rules::ruff::rules::InvalidAssertMessageLiteralArgument),
985985
(Ruff, "041") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryNestedLiteral),
986+
(Ruff, "046") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryCastToInt),
986987
(Ruff, "048") => (RuleGroup::Preview, rules::ruff::rules::MapIntVersionParsing),
987988
(Ruff, "052") => (RuleGroup::Preview, rules::ruff::rules::UsedDummyVariable),
988989
(Ruff, "055") => (RuleGroup::Preview, rules::ruff::rules::UnnecessaryRegularExpression),

crates/ruff_linter/src/rules/ruff/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ mod tests {
413413
#[test_case(Rule::UnrawRePattern, Path::new("RUF039_concat.py"))]
414414
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_0.py"))]
415415
#[test_case(Rule::UnnecessaryRegularExpression, Path::new("RUF055_1.py"))]
416+
#[test_case(Rule::UnnecessaryCastToInt, Path::new("RUF046.py"))]
416417
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
417418
let snapshot = format!(
418419
"preview__{}_{}",

crates/ruff_linter/src/rules/ruff/rules/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub(crate) use sort_dunder_slots::*;
3030
pub(crate) use static_key_dict_comprehension::*;
3131
#[cfg(any(feature = "test-rules", test))]
3232
pub(crate) use test_rules::*;
33+
pub(crate) use unnecessary_cast_to_int::*;
3334
pub(crate) use unnecessary_iterable_allocation_for_first_element::*;
3435
pub(crate) use unnecessary_key_check::*;
3536
pub(crate) use unnecessary_nested_literal::*;
@@ -78,6 +79,7 @@ mod static_key_dict_comprehension;
7879
mod suppression_comment_visitor;
7980
#[cfg(any(feature = "test-rules", test))]
8081
pub(crate) mod test_rules;
82+
mod unnecessary_cast_to_int;
8183
mod unnecessary_iterable_allocation_for_first_element;
8284
mod unnecessary_key_check;
8385
mod unnecessary_nested_literal;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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

Comments
 (0)