From 7a546809c4aa4b47e1c4f864ec84a366ee4f8deb Mon Sep 17 00:00:00 2001 From: chiri Date: Sun, 16 Nov 2025 11:32:41 +0300 Subject: [PATCH] [`refurb`] Fix `FURB103` autofix (#21454) --- .../resources/test/fixtures/refurb/FURB103.py | 11 ++++- .../rules/refurb/rules/write_whole_file.rs | 44 ++++++++----------- ...es__refurb__tests__FURB103_FURB103.py.snap | 31 +++++++++++++ ...rb__tests__write_whole_file_python_39.snap | 31 +++++++++++++ 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/refurb/FURB103.py b/crates/ruff_linter/resources/test/fixtures/refurb/FURB103.py index 35d9600d41..c6a4196fe3 100644 --- a/crates/ruff_linter/resources/test/fixtures/refurb/FURB103.py +++ b/crates/ruff_linter/resources/test/fixtures/refurb/FURB103.py @@ -152,4 +152,13 @@ import json data = {"price": 100} with open("test.json", "wb") as f: - f.write(json.dumps(data, indent=4).encode("utf-8")) \ No newline at end of file + f.write(json.dumps(data, indent=4).encode("utf-8")) + +# See: https://github.com/astral-sh/ruff/issues/21381 +with open("tmp_path/pyproject.toml", "w") as f: + f.write(dedent( + """ + [project] + other = 1.234 + """, + )) diff --git a/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs b/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs index f25faa3eb2..8e4c7319ba 100644 --- a/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs +++ b/crates/ruff_linter/src/rules/refurb/rules/write_whole_file.rs @@ -2,17 +2,15 @@ use ruff_diagnostics::{Applicability, Edit, Fix}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::{ self as ast, Expr, Stmt, - relocate::relocate_expr, visitor::{self, Visitor}, }; -use ruff_python_codegen::Generator; -use ruff_text_size::{Ranged, TextRange}; +use ruff_text_size::Ranged; use crate::checkers::ast::Checker; use crate::fix::snippet::SourceCodeSnippet; use crate::importer::ImportRequest; use crate::rules::refurb::helpers::{FileOpen, find_file_opens}; -use crate::{FixAvailability, Violation}; +use crate::{FixAvailability, Locator, Violation}; /// ## What it does /// Checks for uses of `open` and `write` that can be replaced by `pathlib` @@ -129,7 +127,7 @@ impl<'a> Visitor<'a> for WriteMatcher<'a, '_> { let open = self.candidates.remove(open); if self.loop_counter == 0 { - let suggestion = make_suggestion(&open, content, self.checker.generator()); + let suggestion = make_suggestion(&open, content, self.checker.locator()); let mut diagnostic = self.checker.report_diagnostic( WriteWholeFile { @@ -172,27 +170,21 @@ fn match_write_call(expr: &Expr) -> Option<(&Expr, &Expr)> { Some((&*attr.value, call.arguments.args.first()?)) } -fn make_suggestion(open: &FileOpen<'_>, arg: &Expr, generator: Generator) -> String { - let name = ast::ExprName { - id: open.mode.pathlib_method(), - ctx: ast::ExprContext::Load, - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - }; - let mut arg = arg.clone(); - relocate_expr(&mut arg, TextRange::default()); - let call = ast::ExprCall { - func: Box::new(name.into()), - arguments: ast::Arguments { - args: Box::new([arg]), - keywords: open.keywords.iter().copied().cloned().collect(), - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - }, - range: TextRange::default(), - node_index: ruff_python_ast::AtomicNodeIndex::NONE, - }; - generator.expr(&call.into()) +fn make_suggestion(open: &FileOpen<'_>, arg: &Expr, locator: &Locator) -> String { + let method_name = open.mode.pathlib_method(); + let arg_code = locator.slice(arg.range()); + + if open.keywords.is_empty() { + format!("{method_name}({arg_code})") + } else { + format!( + "{method_name}({arg_code}, {})", + itertools::join( + open.keywords.iter().map(|kw| locator.slice(kw.range())), + ", " + ) + ) + } } fn generate_fix( diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103.py.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103.py.snap index 8148035435..81c35384b9 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103.py.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__FURB103_FURB103.py.snap @@ -279,3 +279,34 @@ help: Replace with `Path("test.json")....` - with open("test.json", "wb") as f: - f.write(json.dumps(data, indent=4).encode("utf-8")) 155 + pathlib.Path("test.json").write_bytes(json.dumps(data, indent=4).encode("utf-8")) +156 | +157 | # See: https://github.com/astral-sh/ruff/issues/21381 +158 | with open("tmp_path/pyproject.toml", "w") as f: + +FURB103 [*] `open` and `write` should be replaced by `Path("tmp_path/pyproject.toml")....` + --> FURB103.py:158:6 + | +157 | # See: https://github.com/astral-sh/ruff/issues/21381 +158 | with open("tmp_path/pyproject.toml", "w") as f: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +159 | f.write(dedent( +160 | """ + | +help: Replace with `Path("tmp_path/pyproject.toml")....` +148 | +149 | # See: https://github.com/astral-sh/ruff/issues/20785 +150 | import json +151 + import pathlib +152 | +153 | data = {"price": 100} +154 | +-------------------------------------------------------------------------------- +156 | f.write(json.dumps(data, indent=4).encode("utf-8")) +157 | +158 | # See: https://github.com/astral-sh/ruff/issues/21381 + - with open("tmp_path/pyproject.toml", "w") as f: + - f.write(dedent( +159 + pathlib.Path("tmp_path/pyproject.toml").write_text(dedent( +160 | """ +161 | [project] +162 | other = 1.234 diff --git a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write_whole_file_python_39.snap b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write_whole_file_python_39.snap index 3b68b110d5..7a5ae0c747 100644 --- a/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write_whole_file_python_39.snap +++ b/crates/ruff_linter/src/rules/refurb/snapshots/ruff_linter__rules__refurb__tests__write_whole_file_python_39.snap @@ -209,3 +209,34 @@ help: Replace with `Path("test.json")....` - with open("test.json", "wb") as f: - f.write(json.dumps(data, indent=4).encode("utf-8")) 155 + pathlib.Path("test.json").write_bytes(json.dumps(data, indent=4).encode("utf-8")) +156 | +157 | # See: https://github.com/astral-sh/ruff/issues/21381 +158 | with open("tmp_path/pyproject.toml", "w") as f: + +FURB103 [*] `open` and `write` should be replaced by `Path("tmp_path/pyproject.toml")....` + --> FURB103.py:158:6 + | +157 | # See: https://github.com/astral-sh/ruff/issues/21381 +158 | with open("tmp_path/pyproject.toml", "w") as f: + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +159 | f.write(dedent( +160 | """ + | +help: Replace with `Path("tmp_path/pyproject.toml")....` +148 | +149 | # See: https://github.com/astral-sh/ruff/issues/20785 +150 | import json +151 + import pathlib +152 | +153 | data = {"price": 100} +154 | +-------------------------------------------------------------------------------- +156 | f.write(json.dumps(data, indent=4).encode("utf-8")) +157 | +158 | # See: https://github.com/astral-sh/ruff/issues/21381 + - with open("tmp_path/pyproject.toml", "w") as f: + - f.write(dedent( +159 + pathlib.Path("tmp_path/pyproject.toml").write_text(dedent( +160 | """ +161 | [project] +162 | other = 1.234