Skip to content

Commit e706ae1

Browse files
committed
Some more docs, smaller nits
1 parent ecafb05 commit e706ae1

1 file changed

Lines changed: 103 additions & 74 deletions

File tree

crates/ruff_linter/src/rules/refurb/rules/fromisoformat_replace_z.rs

Lines changed: 103 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use ruff_diagnostics::{AlwaysFixableViolation, Applicability, Diagnostic, Edit,
22
use ruff_macros::{derive_message_formats, ViolationMetadata};
33
use ruff_python_ast::parenthesize::parenthesized_range;
44
use ruff_python_ast::{
5-
Arguments, Expr, ExprAttribute, ExprBinOp, ExprCall, ExprStringLiteral, ExprSubscript,
6-
ExprUnaryOp, Number, Operator, UnaryOp,
5+
Expr, ExprAttribute, ExprBinOp, ExprCall, ExprStringLiteral, ExprSubscript, ExprUnaryOp,
6+
Number, Operator, UnaryOp,
77
};
88
use ruff_python_semantic::SemanticModel;
99
use ruff_text_size::{Ranged, TextRange};
@@ -88,102 +88,126 @@ pub(crate) fn fromisoformat_replace_z(checker: &Checker, call: &ExprCall) {
8888
return;
8989
}
9090

91-
let Some(range_to_remove) = range_to_remove(argument, checker) else {
91+
let Some(replace_time_zone) = ReplaceTimeZone::from_expr(argument) else {
9292
return;
9393
};
9494

95+
if !is_zero_offset_timezone(replace_time_zone.zero_offset.value.to_str()) {
96+
return;
97+
}
98+
99+
let value_full_range = parenthesized_range(
100+
replace_time_zone.date.into(),
101+
replace_time_zone.parent.into(),
102+
checker.comment_ranges(),
103+
checker.source(),
104+
)
105+
.unwrap_or(replace_time_zone.date.range());
106+
107+
let range_to_remove = TextRange::new(value_full_range.end(), argument.end());
108+
95109
let applicability = if checker.comment_ranges().intersects(range_to_remove) {
96110
Applicability::Unsafe
97111
} else {
98112
Applicability::Safe
99113
};
100114

101-
let edit = Edit::range_deletion(range_to_remove);
102-
let fix = Fix::applicable_edit(edit, applicability);
103-
104115
let diagnostic = Diagnostic::new(FromisoformatReplaceZ, argument.range());
105116

106-
checker.report_diagnostic(diagnostic.with_fix(fix));
117+
checker.report_diagnostic(diagnostic.with_fix(Fix::applicable_edit(
118+
Edit::range_deletion(range_to_remove),
119+
applicability,
120+
)));
107121
}
108122

109123
fn func_is_fromisoformat(func: &Expr, semantic: &SemanticModel) -> bool {
110-
let Some(qualified_name) = semantic.resolve_qualified_name(func) else {
111-
return false;
112-
};
113-
114-
if !matches!(
115-
qualified_name.segments(),
116-
["datetime", "datetime", "fromisoformat"]
117-
) {
118-
return false;
119-
}
120-
121-
true
124+
semantic
125+
.resolve_qualified_name(func)
126+
.is_some_and(|qualified_name| {
127+
matches!(
128+
qualified_name.segments(),
129+
["datetime", "datetime", "fromisoformat"]
130+
)
131+
})
122132
}
123133

124-
fn range_to_remove(expr: &Expr, checker: &Checker) -> Option<TextRange> {
125-
let (date, parent, zero_offset) = match expr {
126-
Expr::Call(ExprCall {
127-
func, arguments, ..
128-
}) => replace_z_date_parent_and_offset(func, arguments)?,
134+
/// A `datetime.replace` call that replaces the timezone with a zero offset.
135+
struct ReplaceTimeZone<'a> {
136+
/// The date expression
137+
date: &'a Expr,
138+
/// The `date` expression's parent.
139+
parent: &'a Expr,
140+
/// The zero offset string literal
141+
zero_offset: &'a ExprStringLiteral,
142+
}
129143

130-
Expr::BinOp(ExprBinOp {
131-
left, op, right, ..
132-
}) => {
133-
if *op != Operator::Add {
134-
return None;
135-
}
136-
137-
let (date, parent) = match &**left {
138-
Expr::Call(call) => strip_z_date_and_parent(call)?,
139-
Expr::Subscript(subscript) => (slice_minus_1_date(subscript)?, &**left),
140-
_ => return None,
141-
};
142-
143-
(date, parent, right.as_string_literal_expr()?)
144+
impl<'a> ReplaceTimeZone<'a> {
145+
fn from_expr(expr: &'a Expr) -> Option<Self> {
146+
match expr {
147+
Expr::Call(call) => Self::from_call(call),
148+
Expr::BinOp(bin_op) => Self::from_bin_op(bin_op),
149+
_ => None,
144150
}
151+
}
145152

146-
_ => return None,
147-
};
153+
/// Returns `Some` if the call expression is a call to `str.replace` and matches `date.replace("Z", "+00:00")`
154+
fn from_call(call: &'a ExprCall) -> Option<Self> {
155+
let arguments = &call.arguments;
148156

149-
if !is_zero_offset_timezone(zero_offset.value.to_str()) {
150-
return None;
151-
}
157+
if !arguments.keywords.is_empty() {
158+
return None;
159+
};
152160

153-
let comment_ranges = checker.comment_ranges();
154-
let source = checker.source();
155-
let value_full_range = parenthesized_range(date.into(), parent.into(), comment_ranges, source)
156-
.unwrap_or(date.range());
161+
let ExprAttribute { value, attr, .. } = call.func.as_attribute_expr()?;
157162

158-
Some(TextRange::new(value_full_range.end(), expr.end()))
159-
}
163+
if attr != "replace" {
164+
return None;
165+
}
160166

161-
fn replace_z_date_parent_and_offset<'a>(
162-
func: &'a Expr,
163-
arguments: &'a Arguments,
164-
) -> Option<(&'a Expr, &'a Expr, &'a ExprStringLiteral)> {
165-
if !arguments.keywords.is_empty() {
166-
return None;
167-
};
167+
let [z, Expr::StringLiteral(zero_offset)] = &*arguments.args else {
168+
return None;
169+
};
168170

169-
let ExprAttribute { value, attr, .. } = func.as_attribute_expr()?;
171+
if !is_upper_case_z_string(z) {
172+
return None;
173+
}
170174

171-
if attr != "replace" {
172-
return None;
175+
Some(Self {
176+
date: &**value,
177+
parent: &*call.func,
178+
zero_offset,
179+
})
173180
}
174181

175-
let [z, Expr::StringLiteral(zero_offset)] = &*arguments.args else {
176-
return None;
177-
};
182+
/// Returns `Some` for binary expressions matching `date[:-1] + "-00"` or
183+
/// `date.strip("Z") + "+00"`
184+
fn from_bin_op(bin_op: &'a ExprBinOp) -> Option<Self> {
185+
let ExprBinOp {
186+
left, op, right, ..
187+
} = bin_op;
178188

179-
if !is_upper_case_z_string(z) {
180-
return None;
181-
}
189+
if *op != Operator::Add {
190+
return None;
191+
}
182192

183-
Some((&**value, func, zero_offset))
193+
let (date, parent) = match &**left {
194+
Expr::Call(call) => strip_z_date(call)?,
195+
Expr::Subscript(subscript) => (slice_minus_1_date(subscript)?, &**left),
196+
_ => return None,
197+
};
198+
199+
Some(Self {
200+
date,
201+
parent,
202+
zero_offset: right.as_string_literal_expr()?,
203+
})
204+
}
184205
}
185206

186-
fn strip_z_date_and_parent(call: &ExprCall) -> Option<(&Expr, &Expr)> {
207+
/// Returns `Some` if `call` is a call to `date.strip("Z")`.
208+
///
209+
/// It returns the value of the `date` argument and its parent.
210+
fn strip_z_date(call: &ExprCall) -> Option<(&Expr, &Expr)> {
187211
let ExprCall {
188212
func, arguments, ..
189213
} = call;
@@ -211,6 +235,7 @@ fn strip_z_date_and_parent(call: &ExprCall) -> Option<(&Expr, &Expr)> {
211235
Some((value, func))
212236
}
213237

238+
/// Returns `Some` if this is a subscribt with the form `date[:-1] + "-00"`.
214239
fn slice_minus_1_date(subscript: &ExprSubscript) -> Option<&Expr> {
215240
let ExprSubscript { value, slice, .. } = subscript;
216241
let slice = slice.as_slice_expr()?;
@@ -219,25 +244,29 @@ fn slice_minus_1_date(subscript: &ExprSubscript) -> Option<&Expr> {
219244
return None;
220245
}
221246

222-
let ExprUnaryOp { operand, op, .. } = slice.upper.as_ref()?.as_unary_op_expr()?;
247+
let Some(ExprUnaryOp {
248+
operand,
249+
op: UnaryOp::USub,
250+
..
251+
}) = slice.upper.as_ref()?.as_unary_op_expr()
252+
else {
253+
return None;
254+
};
223255

224256
let Number::Int(int) = &operand.as_number_literal_expr()?.value else {
225257
return None;
226258
};
227259

228-
if *op != UnaryOp::USub || !matches!(int.as_u8(), Some(1)) {
260+
if *int != 1 {
229261
return None;
230262
}
231263

232264
Some(value)
233265
}
234266

235267
fn is_upper_case_z_string(expr: &Expr) -> bool {
236-
let Expr::StringLiteral(string) = expr else {
237-
return false;
238-
};
239-
240-
string.value.to_str() == "Z"
268+
expr.as_string_literal_expr()
269+
.is_some_and(|string| string.value.to_str() == "Z")
241270
}
242271

243272
fn is_zero_offset_timezone(value: &str) -> bool {

0 commit comments

Comments
 (0)