Skip to content

Commit b66a3e7

Browse files
authored
[refurb] Add fixes for FURB101, FURB103 (#20520)
## Summary Part of `PTH-*` fixes: #19404 (comment) ## Test Plan `cargo nextest run furb`
1 parent 70f51e9 commit b66a3e7

10 files changed

+698
-34
lines changed

crates/ruff_linter/src/preview.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,13 @@ pub(crate) const fn is_b006_unsafe_fix_preserve_assignment_expr_enabled(
259259
) -> bool {
260260
settings.preview.is_enabled()
261261
}
262+
263+
// https://github.com/astral-sh/ruff/pull/20520
264+
pub(crate) const fn is_fix_read_whole_file_enabled(settings: &LinterSettings) -> bool {
265+
settings.preview.is_enabled()
266+
}
267+
268+
// https://github.com/astral-sh/ruff/pull/20520
269+
pub(crate) const fn is_fix_write_whole_file_enabled(settings: &LinterSettings) -> bool {
270+
settings.preview.is_enabled()
271+
}

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
use std::borrow::Cow;
22

3-
use ruff_python_ast::name::Name;
4-
use ruff_python_ast::{self as ast, Expr, parenthesize::parenthesized_range};
3+
use ruff_python_ast::PythonVersion;
4+
use ruff_python_ast::{self as ast, Expr, name::Name, parenthesize::parenthesized_range};
55
use ruff_python_codegen::Generator;
66
use ruff_python_semantic::{BindingId, ResolvedReference, SemanticModel};
77
use ruff_text_size::{Ranged, TextRange};
88

99
use crate::checkers::ast::Checker;
1010
use crate::{Applicability, Edit, Fix};
11-
use ruff_python_ast::PythonVersion;
1211

1312
/// Format a code snippet to call `name.method()`.
1413
pub(super) fn generate_method_call(name: Name, method: &str, generator: Generator) -> String {
@@ -345,12 +344,8 @@ pub(super) fn parenthesize_loop_iter_if_necessary<'a>(
345344
let iter_in_source = locator.slice(iter);
346345

347346
match iter {
348-
ast::Expr::Tuple(tuple) if !tuple.parenthesized => {
349-
Cow::Owned(format!("({iter_in_source})"))
350-
}
351-
ast::Expr::Lambda(_) | ast::Expr::If(_)
352-
if matches!(location, IterLocation::Comprehension) =>
353-
{
347+
Expr::Tuple(tuple) if !tuple.parenthesized => Cow::Owned(format!("({iter_in_source})")),
348+
Expr::Lambda(_) | Expr::If(_) if matches!(location, IterLocation::Comprehension) => {
354349
Cow::Owned(format!("({iter_in_source})"))
355350
}
356351
_ => Cow::Borrowed(iter_in_source),

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod tests {
1212
use test_case::test_case;
1313

1414
use crate::registry::Rule;
15+
use crate::settings::types::PreviewMode;
1516
use crate::test::test_path;
1617
use crate::{assert_diagnostics, settings};
1718

@@ -62,6 +63,25 @@ mod tests {
6263
Ok(())
6364
}
6465

66+
#[test_case(Rule::ReadWholeFile, Path::new("FURB101.py"))]
67+
#[test_case(Rule::WriteWholeFile, Path::new("FURB103.py"))]
68+
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
69+
let snapshot = format!(
70+
"preview_{}_{}",
71+
rule_code.noqa_code(),
72+
path.to_string_lossy()
73+
);
74+
let diagnostics = test_path(
75+
Path::new("refurb").join(path).as_path(),
76+
&settings::LinterSettings {
77+
preview: PreviewMode::Enabled,
78+
..settings::LinterSettings::for_rule(rule_code)
79+
},
80+
)?;
81+
assert_diagnostics!(snapshot, diagnostics);
82+
Ok(())
83+
}
84+
6585
#[test]
6686
fn write_whole_file_python_39() -> Result<()> {
6787
let diagnostics = test_path(

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

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
use ruff_diagnostics::{Applicability, Edit, Fix};
12
use ruff_macros::{ViolationMetadata, derive_message_formats};
2-
use ruff_python_ast::visitor::{self, Visitor};
3-
use ruff_python_ast::{self as ast, Expr};
3+
use ruff_python_ast::{
4+
self as ast, Expr, Stmt,
5+
visitor::{self, Visitor},
6+
};
47
use ruff_python_codegen::Generator;
58
use ruff_text_size::{Ranged, TextRange};
69

7-
use crate::Violation;
810
use crate::checkers::ast::Checker;
911
use crate::fix::snippet::SourceCodeSnippet;
10-
12+
use crate::importer::ImportRequest;
1113
use crate::rules::refurb::helpers::{FileOpen, find_file_opens};
14+
use crate::{FixAvailability, Violation};
1215

1316
/// ## What it does
1417
/// Checks for uses of `open` and `read` that can be replaced by `pathlib`
@@ -31,6 +34,8 @@ use crate::rules::refurb::helpers::{FileOpen, find_file_opens};
3134
///
3235
/// contents = Path(filename).read_text()
3336
/// ```
37+
/// ## Fix Safety
38+
/// This rule's fix is marked as unsafe if the replacement would remove comments attached to the original expression.
3439
///
3540
/// ## References
3641
/// - [Python documentation: `Path.read_bytes`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_bytes)
@@ -42,12 +47,22 @@ pub(crate) struct ReadWholeFile {
4247
}
4348

4449
impl Violation for ReadWholeFile {
50+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
51+
4552
#[derive_message_formats]
4653
fn message(&self) -> String {
4754
let filename = self.filename.truncated_display();
4855
let suggestion = self.suggestion.truncated_display();
4956
format!("`open` and `read` should be replaced by `Path({filename}).{suggestion}`")
5057
}
58+
59+
fn fix_title(&self) -> Option<String> {
60+
Some(format!(
61+
"Replace with `Path({}).{}`",
62+
self.filename.truncated_display(),
63+
self.suggestion.truncated_display(),
64+
))
65+
}
5166
}
5267

5368
/// FURB101
@@ -64,21 +79,27 @@ pub(crate) fn read_whole_file(checker: &Checker, with: &ast::StmtWith) {
6479
}
6580

6681
// Then we need to match each `open` operation with exactly one `read` call.
67-
let mut matcher = ReadMatcher::new(checker, candidates);
82+
let mut matcher = ReadMatcher::new(checker, candidates, with);
6883
visitor::walk_body(&mut matcher, &with.body);
6984
}
7085

7186
/// AST visitor that matches `open` operations with the corresponding `read` calls.
7287
struct ReadMatcher<'a, 'b> {
7388
checker: &'a Checker<'b>,
7489
candidates: Vec<FileOpen<'a>>,
90+
with_stmt: &'a ast::StmtWith,
7591
}
7692

7793
impl<'a, 'b> ReadMatcher<'a, 'b> {
78-
fn new(checker: &'a Checker<'b>, candidates: Vec<FileOpen<'a>>) -> Self {
94+
fn new(
95+
checker: &'a Checker<'b>,
96+
candidates: Vec<FileOpen<'a>>,
97+
with_stmt: &'a ast::StmtWith,
98+
) -> Self {
7999
Self {
80100
checker,
81101
candidates,
102+
with_stmt,
82103
}
83104
}
84105
}
@@ -92,15 +113,38 @@ impl<'a> Visitor<'a> for ReadMatcher<'a, '_> {
92113
.position(|open| open.is_ref(read_from))
93114
{
94115
let open = self.candidates.remove(open);
95-
self.checker.report_diagnostic(
116+
let suggestion = make_suggestion(&open, self.checker.generator());
117+
let mut diagnostic = self.checker.report_diagnostic(
96118
ReadWholeFile {
97119
filename: SourceCodeSnippet::from_str(
98120
&self.checker.generator().expr(open.filename),
99121
),
100-
suggestion: make_suggestion(&open, self.checker.generator()),
122+
suggestion: SourceCodeSnippet::from_str(&suggestion),
101123
},
102124
open.item.range(),
103125
);
126+
127+
if !crate::preview::is_fix_read_whole_file_enabled(self.checker.settings()) {
128+
return;
129+
}
130+
131+
let target = match self.with_stmt.body.first() {
132+
Some(Stmt::Assign(assign))
133+
if assign.value.range().contains_range(expr.range()) =>
134+
{
135+
match assign.targets.first() {
136+
Some(Expr::Name(name)) => Some(name.id.as_str()),
137+
_ => None,
138+
}
139+
}
140+
_ => None,
141+
};
142+
143+
if let Some(fix) =
144+
generate_fix(self.checker, &open, target, self.with_stmt, &suggestion)
145+
{
146+
diagnostic.set_fix(fix);
147+
}
104148
}
105149
return;
106150
}
@@ -125,7 +169,7 @@ fn match_read_call(expr: &Expr) -> Option<&Expr> {
125169
Some(&*attr.value)
126170
}
127171

128-
fn make_suggestion(open: &FileOpen<'_>, generator: Generator) -> SourceCodeSnippet {
172+
fn make_suggestion(open: &FileOpen<'_>, generator: Generator) -> String {
129173
let name = ast::ExprName {
130174
id: open.mode.pathlib_method(),
131175
ctx: ast::ExprContext::Load,
@@ -143,5 +187,46 @@ fn make_suggestion(open: &FileOpen<'_>, generator: Generator) -> SourceCodeSnipp
143187
range: TextRange::default(),
144188
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
145189
};
146-
SourceCodeSnippet::from_str(&generator.expr(&call.into()))
190+
generator.expr(&call.into())
191+
}
192+
193+
fn generate_fix(
194+
checker: &Checker,
195+
open: &FileOpen,
196+
target: Option<&str>,
197+
with_stmt: &ast::StmtWith,
198+
suggestion: &str,
199+
) -> Option<Fix> {
200+
if !(with_stmt.items.len() == 1 && matches!(with_stmt.body.as_slice(), [Stmt::Assign(_)])) {
201+
return None;
202+
}
203+
204+
let locator = checker.locator();
205+
let filename_code = locator.slice(open.filename.range());
206+
207+
let (import_edit, binding) = checker
208+
.importer()
209+
.get_or_import_symbol(
210+
&ImportRequest::import("pathlib", "Path"),
211+
with_stmt.start(),
212+
checker.semantic(),
213+
)
214+
.ok()?;
215+
216+
let replacement = match target {
217+
Some(var) => format!("{var} = {binding}({filename_code}).{suggestion}"),
218+
None => format!("{binding}({filename_code}).{suggestion}"),
219+
};
220+
221+
let applicability = if checker.comment_ranges().intersects(with_stmt.range()) {
222+
Applicability::Unsafe
223+
} else {
224+
Applicability::Safe
225+
};
226+
227+
Some(Fix::applicable_edits(
228+
Edit::range_replacement(replacement, with_stmt.range()),
229+
[import_edit],
230+
applicability,
231+
))
147232
}

0 commit comments

Comments
 (0)