Skip to content

Commit b5a8638

Browse files
committed
refactor: make filename optional
Signed-off-by: 11happy <[email protected]>
1 parent 998b130 commit b5a8638

10 files changed

+409
-478
lines changed

crates/ruff_linter/resources/test/fixtures/refurb/FURB103.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from pathlib import Path
21
def foo():
32
...
43

@@ -155,20 +154,4 @@ def bar(x):
155154
with open("test.json", "wb") as f:
156155
f.write(json.dumps(data, indent=4).encode("utf-8"))
157156

158-
with Path("file.txt").open("w") as f:
159-
f.write("test")
160-
161-
with Path("file.txt").open("wb") as f:
162-
f.write(b"test")
163157

164-
with Path("file.txt").open(mode="w") as f:
165-
f.write("test")
166-
167-
with Path("file.txt").open("w", encoding="utf8") as f:
168-
f.write("test")
169-
170-
with Path("file.txt").open("w", errors="ignore") as f:
171-
f.write("test")
172-
173-
with Path(foo()).open("w") as f:
174-
f.write("test")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pathlib import Path
2+
3+
with Path("file.txt").open("w") as f:
4+
f.write("test")
5+
6+
with Path("file.txt").open("wb") as f:
7+
f.write(b"test")
8+
9+
with Path("file.txt").open(mode="w") as f:
10+
f.write("test")
11+
12+
with Path("file.txt").open("w", encoding="utf8") as f:
13+
f.write("test")
14+
15+
with Path("file.txt").open("w", errors="ignore") as f:
16+
f.write("test")
17+
18+
with Path(foo()).open("w") as f:
19+
f.write("test")
20+
21+
p = Path("file.txt")
22+
with p.open("w") as f:
23+
f.write("test")

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

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

3-
use crate::checkers::ast::Checker;
4-
use crate::rules::flake8_async::rules::blocking_open_call::is_open_call_from_pathlib;
5-
use crate::{Applicability, Edit, Fix};
63
use ruff_python_ast::PythonVersion;
74
use ruff_python_ast::{self as ast, Expr, name::Name, parenthesize::parenthesized_range};
85
use ruff_python_codegen::Generator;
9-
use ruff_python_semantic::{BindingId, ResolvedReference, SemanticModel};
6+
use ruff_python_semantic::{ResolvedReference, SemanticModel};
107
use ruff_text_size::{Ranged, TextRange};
118

9+
use crate::checkers::ast::Checker;
10+
use crate::rules::flake8_async::rules::blocking_open_call::is_open_call_from_pathlib;
11+
use crate::{Applicability, Edit, Fix};
12+
1213
/// Format a code snippet to call `name.method()`.
1314
pub(super) fn generate_method_call(name: Name, method: &str, generator: Generator) -> String {
1415
// Construct `name`.
@@ -120,14 +121,14 @@ pub(super) struct FileOpen<'a> {
120121
/// With item where the open happens, we use it for the reporting range.
121122
pub(super) item: &'a ast::WithItem,
122123
/// Filename expression used as the first argument in `open`, we use it in the diagnostic message.
123-
pub(super) filename: &'a Expr,
124+
pub(super) filename: Option<&'a Expr>,
124125
/// The file open mode.
125126
pub(super) mode: OpenMode,
126127
/// The file open keywords.
127128
pub(super) keywords: Vec<&'a ast::Keyword>,
128129
/// We only check `open` operations whose file handles are used exactly once.
129130
pub(super) reference: &'a ResolvedReference,
130-
/// `Path().open()` call
131+
/// Pathlib Path object used to open the file, if any.
131132
pub(super) path_obj: Option<&'a Expr>,
132133
}
133134

@@ -155,12 +156,13 @@ pub(super) fn find_file_opens<'a>(
155156
.collect()
156157
}
157158

159+
#[expect(clippy::too_many_arguments)]
158160
fn resolve_file_open<'a>(
159161
item: &'a ast::WithItem,
160162
with: &'a ast::StmtWith,
161163
semantic: &'a SemanticModel<'a>,
162164
read_mode: bool,
163-
filename: &'a Expr,
165+
filename: Option<&'a Expr>,
164166
mode: OpenMode,
165167
keywords: Vec<&'a ast::Keyword>,
166168
path_obj: Option<&'a Expr>,
@@ -181,15 +183,13 @@ fn resolve_file_open<'a>(
181183
if matches!(mode, OpenMode::ReadBytes | OpenMode::WriteBytes) && !keywords.is_empty() {
182184
return None;
183185
}
184-
185186
let var = item.optional_vars.as_deref()?.as_name_expr()?;
186187
let scope = semantic.current_scope();
187188

188189
let binding = scope.get_all(var.id.as_str()).find_map(|id| {
189190
let b = semantic.binding(id);
190191
(b.range() == var.range()).then_some(b)
191192
})?;
192-
193193
let references: Vec<&ResolvedReference> = binding
194194
.references
195195
.iter()
@@ -247,7 +247,14 @@ fn find_file_open<'a>(
247247

248248
let mode = kw_mode.unwrap_or(pos_mode);
249249
resolve_file_open(
250-
item, with, semantic, read_mode, filename, mode, keywords, None,
250+
item,
251+
with,
252+
semantic,
253+
read_mode,
254+
Some(filename),
255+
mode,
256+
keywords,
257+
None,
251258
)
252259
}
253260

@@ -263,17 +270,26 @@ fn find_path_open<'a>(
263270
arguments: ast::Arguments { args, keywords, .. },
264271
..
265272
} = item.context_expr.as_call_expr()?;
273+
if args.iter().any(Expr::is_starred_expr)
274+
|| keywords.iter().any(|keyword| keyword.arg.is_none())
275+
{
276+
return None;
277+
}
266278
if !is_open_call_from_pathlib(func, semantic) {
267279
return None;
268280
}
269281
let attr = func.as_attribute_expr()?;
270-
let path_call = attr.value.as_call_expr()?;
271-
let filename = path_call.arguments.args.first()?;
282+
let filename = if let Expr::Call(path_call) = attr.value.as_ref() {
283+
path_call.arguments.args.first()
284+
} else {
285+
None
286+
};
272287
let mode = if args.is_empty() {
273288
OpenMode::ReadText
274289
} else {
275290
match_open_mode(args.first()?)?
276291
};
292+
277293
let (keywords, kw_mode) = match_open_keywords(keywords, read_mode, python_version)?;
278294
let mode = kw_mode.unwrap_or(mode);
279295
resolve_file_open(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod tests {
4848
#[test_case(Rule::HashlibDigestHex, Path::new("FURB181.py"))]
4949
#[test_case(Rule::ListReverseCopy, Path::new("FURB187.py"))]
5050
#[test_case(Rule::WriteWholeFile, Path::new("FURB103.py"))]
51+
#[test_case(Rule::WriteWholeFile, Path::new("FURB103_EXT.py"))]
5152
#[test_case(Rule::FStringNumberFormat, Path::new("FURB116.py"))]
5253
#[test_case(Rule::SortedMinMax, Path::new("FURB192.py"))]
5354
#[test_case(Rule::SliceToRemovePrefixOrSuffix, Path::new("FURB188.py"))]

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,17 @@ impl<'a> Visitor<'a> for ReadMatcher<'a, '_> {
114114
.position(|open| open.is_ref(read_from))
115115
{
116116
let open = self.candidates.remove(open);
117+
let filename_display = if let Some(filename) = open.filename {
118+
self.checker.generator().expr(filename)
119+
} else if let Some(path_obj) = open.path_obj {
120+
self.checker.locator().slice(path_obj.range()).to_string()
121+
} else {
122+
return;
123+
};
117124
let suggestion = make_suggestion(&open, self.checker.generator());
118125
let mut diagnostic = self.checker.report_diagnostic(
119126
ReadWholeFile {
120-
filename: SourceCodeSnippet::from_str(
121-
&self.checker.generator().expr(open.filename),
122-
),
127+
filename: SourceCodeSnippet::from_str(&filename_display),
123128
suggestion: SourceCodeSnippet::from_str(&suggestion),
124129
},
125130
open.item.range(),
@@ -201,9 +206,9 @@ fn generate_fix(
201206
if !(with_stmt.items.len() == 1 && matches!(with_stmt.body.as_slice(), [Stmt::Assign(_)])) {
202207
return None;
203208
}
204-
209+
let filename = open.filename?;
205210
let locator = checker.locator();
206-
let filename_code = locator.slice(open.filename.range());
211+
let filename_code = locator.slice(filename.range());
207212

208213
let (import_edit, binding) = checker
209214
.importer()

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub(crate) struct WriteWholeFile {
4848
filename: SourceCodeSnippet,
4949
suggestion: SourceCodeSnippet,
5050
is_path_open: bool,
51+
has_filename: bool,
5152
}
5253

5354
impl Violation for WriteWholeFile {
@@ -58,19 +59,35 @@ impl Violation for WriteWholeFile {
5859
let filename = self.filename.truncated_display();
5960
let suggestion = self.suggestion.truncated_display();
6061
if self.is_path_open {
61-
format!(
62-
"`Path.open()` followed by `write()` can be replaced by `Path({filename}).{suggestion}`"
63-
)
62+
if self.has_filename {
63+
format!(
64+
"`Path().open()` followed by `write()` can be replaced by `Path({filename}).{suggestion}`"
65+
)
66+
} else {
67+
format!(
68+
"`Path.open()` followed by `write()` can be replaced by `{filename}.{suggestion}`"
69+
)
70+
}
6471
} else {
6572
format!("`open` and `write` should be replaced by `Path({filename}).{suggestion}`")
6673
}
6774
}
6875
fn fix_title(&self) -> Option<String> {
69-
Some(format!(
70-
"Replace with `Path({}).{}`",
71-
self.filename.truncated_display(),
72-
self.suggestion.truncated_display(),
73-
))
76+
let formatted = if self.has_filename {
77+
format!(
78+
"Replace with `Path({}).{}`",
79+
self.filename.truncated_display(),
80+
self.suggestion.truncated_display(),
81+
)
82+
} else {
83+
format!(
84+
"Replace with `{}.{}`",
85+
self.filename.truncated_display(),
86+
self.suggestion.truncated_display(),
87+
)
88+
};
89+
90+
Some(formatted)
7491
}
7592
}
7693

@@ -134,17 +151,27 @@ impl<'a> Visitor<'a> for WriteMatcher<'a, '_> {
134151
.position(|open| open.is_ref(write_to))
135152
{
136153
let open = self.candidates.remove(open);
137-
154+
let has_filename: bool;
155+
let filename_display: String;
138156
if self.loop_counter == 0 {
157+
if let Some(filename) = open.filename {
158+
filename_display = self.checker.generator().expr(filename);
159+
has_filename = true;
160+
} else if let Some(path_obj) = open.path_obj {
161+
filename_display =
162+
self.checker.locator().slice(path_obj.range()).to_string();
163+
has_filename = false;
164+
} else {
165+
return;
166+
}
139167
let suggestion = make_suggestion(&open, content, self.checker.generator());
140168

141169
let mut diagnostic = self.checker.report_diagnostic(
142170
WriteWholeFile {
143-
filename: SourceCodeSnippet::from_str(
144-
&self.checker.generator().expr(open.filename),
145-
),
171+
filename: SourceCodeSnippet::from_str(&filename_display),
146172
suggestion: SourceCodeSnippet::from_str(&suggestion),
147173
is_path_open: open.path_obj.is_some(),
174+
has_filename,
148175
},
149176
open.item.range(),
150177
);
@@ -218,7 +245,8 @@ fn generate_fix(
218245
}
219246

220247
let locator = checker.locator();
221-
let filename_code = locator.slice(open.filename.range());
248+
let filename = open.filename?;
249+
let filename_code = locator.slice(filename.range());
222250

223251
let (import_edit, binding) = checker
224252
.importer()

0 commit comments

Comments
 (0)