diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/PTH201.py b/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/PTH201.py index ba495cc588..852e060f70 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/PTH201.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_use_pathlib/PTH201.py @@ -1,15 +1,70 @@ from pathlib import Path, PurePath from pathlib import Path as pth + # match _ = Path(".") _ = pth(".") _ = PurePath(".") _ = Path("") +Path('', ) + +Path( + '', +) + +Path( # Comment before argument + '', +) + +Path( + '', # EOL comment +) + +Path( + '' # Comment in the middle of implicitly concatenated string + ".", +) + +Path( + '' # Comment before comma + , +) + +Path( + '', +) / "bare" + +Path( # Comment before argument + '', +) / ("parenthesized") + +Path( + '', # EOL comment +) / ( ("double parenthesized" ) ) + +( Path( + '' # Comment in the middle of implicitly concatenated string + ".", +) )/ (("parenthesized path call") + # Comment between closing parentheses +) + +Path( + '' # Comment before comma + , +) / "multiple" / ( + "frag" # Comment + 'ment' +) + + # no match _ = Path() print(".") Path("file.txt") Path(".", "folder") PurePath(".", "folder") + +Path() diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index a3d753750f..052d54d652 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -983,7 +983,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { flake8_use_pathlib::rules::replaceable_by_pathlib(checker, call); } if checker.enabled(Rule::PathConstructorCurrentDirectory) { - flake8_use_pathlib::rules::path_constructor_current_directory(checker, expr, func); + flake8_use_pathlib::rules::path_constructor_current_directory(checker, call); } if checker.enabled(Rule::OsSepSplit) { flake8_use_pathlib::rules::os_sep_split(checker, call); diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs index bf8bf7ea3c..82eee8b655 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/rules/path_constructor_current_directory.rs @@ -1,8 +1,15 @@ -use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; +use std::ops::Range; + +use ruff_diagnostics::{AlwaysFixableViolation, Applicability, Diagnostic, Edit, Fix}; use ruff_macros::{derive_message_formats, ViolationMetadata}; -use ruff_python_ast::{self as ast, Expr, ExprCall}; +use ruff_python_ast::parenthesize::parenthesized_range; +use ruff_python_ast::{AstNode, Expr, ExprBinOp, ExprCall, Operator}; +use ruff_python_semantic::SemanticModel; +use ruff_python_trivia::CommentRanges; +use ruff_text_size::{Ranged, TextRange}; use crate::checkers::ast::Checker; +use crate::fix::edits::{remove_argument, Parentheses}; /// ## What it does /// Checks for `pathlib.Path` objects that are initialized with the current @@ -43,7 +50,17 @@ impl AlwaysFixableViolation for PathConstructorCurrentDirectory { } /// PTH201 -pub(crate) fn path_constructor_current_directory(checker: &mut Checker, expr: &Expr, func: &Expr) { +pub(crate) fn path_constructor_current_directory(checker: &mut Checker, call: &ExprCall) { + let applicability = |range| { + if checker.comment_ranges().intersects(range) { + Applicability::Unsafe + } else { + Applicability::Safe + } + }; + + let (func, arguments) = (&call.func, &call.arguments); + if !checker .semantic() .resolve_qualified_name(func) @@ -54,21 +71,75 @@ pub(crate) fn path_constructor_current_directory(checker: &mut Checker, expr: &E return; } - let Expr::Call(ExprCall { arguments, .. }) = expr else { - return; - }; - if !arguments.keywords.is_empty() { return; } - let [Expr::StringLiteral(ast::ExprStringLiteral { value, range })] = &*arguments.args else { + let [Expr::StringLiteral(arg)] = &*arguments.args else { return; }; - if matches!(value.to_str(), "" | ".") { - let mut diagnostic = Diagnostic::new(PathConstructorCurrentDirectory, *range); - diagnostic.set_fix(Fix::safe_edit(Edit::range_deletion(*range))); - checker.diagnostics.push(diagnostic); + if !matches!(arg.value.to_str(), "" | ".") { + return; } + + let mut diagnostic = Diagnostic::new(PathConstructorCurrentDirectory, arg.range()); + + match parent_and_next_path_fragment_range( + checker.semantic(), + checker.comment_ranges(), + checker.source(), + ) { + Some((parent_range, next_fragment_range)) => { + let next_fragment_expr = checker.locator().slice(next_fragment_range); + let call_expr = checker.locator().slice(call.range()); + + let relative_argument_range: Range = { + let range = arg.range() - call.start(); + range.start().into()..range.end().into() + }; + + let mut new_call_expr = call_expr.to_string(); + new_call_expr.replace_range(relative_argument_range, next_fragment_expr); + + let edit = Edit::range_replacement(new_call_expr, parent_range); + + diagnostic.set_fix(Fix::applicable_edit(edit, applicability(parent_range))); + } + None => diagnostic.try_set_fix(|| { + let edit = remove_argument(arg, arguments, Parentheses::Preserve, checker.source())?; + Ok(Fix::applicable_edit(edit, applicability(call.range()))) + }), + }; + + checker.diagnostics.push(diagnostic); +} + +fn parent_and_next_path_fragment_range( + semantic: &SemanticModel, + comment_ranges: &CommentRanges, + source: &str, +) -> Option<(TextRange, TextRange)> { + let parent = semantic.current_expression_parent()?; + + let Expr::BinOp(parent @ ExprBinOp { op, right, .. }) = parent else { + return None; + }; + + let range = right.range(); + + if !matches!(op, Operator::Div) { + return None; + } + + Some(( + parent.range(), + parenthesized_range( + right.into(), + parent.as_any_node_ref(), + comment_ranges, + source, + ) + .unwrap_or(range), + )) } diff --git a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap index 2b0828e70a..cc4d0b55a0 100644 --- a/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap +++ b/crates/ruff_linter/src/rules/flake8_use_pathlib/snapshots/ruff_linter__rules__flake8_use_pathlib__tests__PTH201_PTH201.py.snap @@ -2,84 +2,329 @@ source: crates/ruff_linter/src/rules/flake8_use_pathlib/mod.rs snapshot_kind: text --- -PTH201.py:5:10: PTH201 [*] Do not pass the current directory explicitly to `Path` +PTH201.py:6:10: PTH201 [*] Do not pass the current directory explicitly to `Path` | -4 | # match -5 | _ = Path(".") +5 | # match +6 | _ = Path(".") | ^^^ PTH201 -6 | _ = pth(".") -7 | _ = PurePath(".") +7 | _ = pth(".") +8 | _ = PurePath(".") | = help: Remove the current directory argument ℹ Safe fix -2 2 | from pathlib import Path as pth 3 3 | -4 4 | # match -5 |-_ = Path(".") - 5 |+_ = Path() -6 6 | _ = pth(".") -7 7 | _ = PurePath(".") -8 8 | _ = Path("") +4 4 | +5 5 | # match +6 |-_ = Path(".") + 6 |+_ = Path() +7 7 | _ = pth(".") +8 8 | _ = PurePath(".") +9 9 | _ = Path("") -PTH201.py:6:9: PTH201 [*] Do not pass the current directory explicitly to `Path` +PTH201.py:7:9: PTH201 [*] Do not pass the current directory explicitly to `Path` | -4 | # match -5 | _ = Path(".") -6 | _ = pth(".") +5 | # match +6 | _ = Path(".") +7 | _ = pth(".") | ^^^ PTH201 -7 | _ = PurePath(".") -8 | _ = Path("") +8 | _ = PurePath(".") +9 | _ = Path("") | = help: Remove the current directory argument ℹ Safe fix -3 3 | -4 4 | # match -5 5 | _ = Path(".") -6 |-_ = pth(".") - 6 |+_ = pth() -7 7 | _ = PurePath(".") -8 8 | _ = Path("") -9 9 | +4 4 | +5 5 | # match +6 6 | _ = Path(".") +7 |-_ = pth(".") + 7 |+_ = pth() +8 8 | _ = PurePath(".") +9 9 | _ = Path("") +10 10 | -PTH201.py:7:14: PTH201 [*] Do not pass the current directory explicitly to `Path` +PTH201.py:8:14: PTH201 [*] Do not pass the current directory explicitly to `Path` | -5 | _ = Path(".") -6 | _ = pth(".") -7 | _ = PurePath(".") +6 | _ = Path(".") +7 | _ = pth(".") +8 | _ = PurePath(".") | ^^^ PTH201 -8 | _ = Path("") +9 | _ = Path("") | = help: Remove the current directory argument ℹ Safe fix -4 4 | # match -5 5 | _ = Path(".") -6 6 | _ = pth(".") -7 |-_ = PurePath(".") - 7 |+_ = PurePath() -8 8 | _ = Path("") -9 9 | -10 10 | # no match +5 5 | # match +6 6 | _ = Path(".") +7 7 | _ = pth(".") +8 |-_ = PurePath(".") + 8 |+_ = PurePath() +9 9 | _ = Path("") +10 10 | +11 11 | Path('', ) -PTH201.py:8:10: PTH201 [*] Do not pass the current directory explicitly to `Path` +PTH201.py:9:10: PTH201 [*] Do not pass the current directory explicitly to `Path` | - 6 | _ = pth(".") - 7 | _ = PurePath(".") - 8 | _ = Path("") + 7 | _ = pth(".") + 8 | _ = PurePath(".") + 9 | _ = Path("") | ^^ PTH201 - 9 | -10 | # no match +10 | +11 | Path('', ) | = help: Remove the current directory argument ℹ Safe fix -5 5 | _ = Path(".") -6 6 | _ = pth(".") -7 7 | _ = PurePath(".") -8 |-_ = Path("") - 8 |+_ = Path() -9 9 | -10 10 | # no match -11 11 | _ = Path() +6 6 | _ = Path(".") +7 7 | _ = pth(".") +8 8 | _ = PurePath(".") +9 |-_ = Path("") + 9 |+_ = Path() +10 10 | +11 11 | Path('', ) +12 12 | + +PTH201.py:11:6: PTH201 [*] Do not pass the current directory explicitly to `Path` + | + 9 | _ = Path("") +10 | +11 | Path('', ) + | ^^ PTH201 +12 | +13 | Path( + | + = help: Remove the current directory argument + +ℹ Safe fix +8 8 | _ = PurePath(".") +9 9 | _ = Path("") +10 10 | +11 |-Path('', ) + 11 |+Path() +12 12 | +13 13 | Path( +14 14 | '', + +PTH201.py:14:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +13 | Path( +14 | '', + | ^^ PTH201 +15 | ) + | + = help: Remove the current directory argument + +ℹ Safe fix +10 10 | +11 11 | Path('', ) +12 12 | +13 |-Path( +14 |- '', +15 |-) + 13 |+Path() +16 14 | +17 15 | Path( # Comment before argument +18 16 | '', + +PTH201.py:18:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +17 | Path( # Comment before argument +18 | '', + | ^^ PTH201 +19 | ) + | + = help: Remove the current directory argument + +ℹ Unsafe fix +14 14 | '', +15 15 | ) +16 16 | +17 |-Path( # Comment before argument +18 |- '', +19 |-) + 17 |+Path() +20 18 | +21 19 | Path( +22 20 | '', # EOL comment + +PTH201.py:22:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +21 | Path( +22 | '', # EOL comment + | ^^ PTH201 +23 | ) + | + = help: Remove the current directory argument + +ℹ Unsafe fix +18 18 | '', +19 19 | ) +20 20 | +21 |-Path( +22 |- '', # EOL comment +23 |-) + 21 |+Path() +24 22 | +25 23 | Path( +26 24 | '' # Comment in the middle of implicitly concatenated string + +PTH201.py:26:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +25 | Path( +26 | '' # Comment in the middle of implicitly concatenated string + | _____^ +27 | | ".", + | |_______^ PTH201 +28 | ) + | + = help: Remove the current directory argument + +ℹ Unsafe fix +22 22 | '', # EOL comment +23 23 | ) +24 24 | +25 |-Path( +26 |- '' # Comment in the middle of implicitly concatenated string +27 |- ".", +28 |-) + 25 |+Path() +29 26 | +30 27 | Path( +31 28 | '' # Comment before comma + +PTH201.py:31:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +30 | Path( +31 | '' # Comment before comma + | ^^ PTH201 +32 | , +33 | ) + | + = help: Remove the current directory argument + +ℹ Unsafe fix +27 27 | ".", +28 28 | ) +29 29 | +30 |-Path( +31 |- '' # Comment before comma +32 |- , +33 |-) + 30 |+Path() +34 31 | +35 32 | Path( +36 33 | '', + +PTH201.py:36:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +35 | Path( +36 | '', + | ^^ PTH201 +37 | ) / "bare" + | + = help: Remove the current directory argument + +ℹ Safe fix +33 33 | ) +34 34 | +35 35 | Path( +36 |- '', +37 |-) / "bare" + 36 |+ "bare", + 37 |+) +38 38 | +39 39 | Path( # Comment before argument +40 40 | '', + +PTH201.py:40:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +39 | Path( # Comment before argument +40 | '', + | ^^ PTH201 +41 | ) / ("parenthesized") + | + = help: Remove the current directory argument + +ℹ Unsafe fix +37 37 | ) / "bare" +38 38 | +39 39 | Path( # Comment before argument +40 |- '', +41 |-) / ("parenthesized") + 40 |+ ("parenthesized"), + 41 |+) +42 42 | +43 43 | Path( +44 44 | '', # EOL comment + +PTH201.py:44:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +43 | Path( +44 | '', # EOL comment + | ^^ PTH201 +45 | ) / ( ("double parenthesized" ) ) + | + = help: Remove the current directory argument + +ℹ Unsafe fix +41 41 | ) / ("parenthesized") +42 42 | +43 43 | Path( +44 |- '', # EOL comment +45 |-) / ( ("double parenthesized" ) ) + 44 |+ ( ("double parenthesized" ) ), # EOL comment + 45 |+) +46 46 | +47 47 | ( Path( +48 48 | '' # Comment in the middle of implicitly concatenated string + +PTH201.py:48:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +47 | ( Path( +48 | '' # Comment in the middle of implicitly concatenated string + | _____^ +49 | | ".", + | |_______^ PTH201 +50 | ) )/ (("parenthesized path call") +51 | # Comment between closing parentheses + | + = help: Remove the current directory argument + +ℹ Unsafe fix +44 44 | '', # EOL comment +45 45 | ) / ( ("double parenthesized" ) ) +46 46 | +47 |-( Path( +48 |- '' # Comment in the middle of implicitly concatenated string +49 |- ".", +50 |-) )/ (("parenthesized path call") + 47 |+Path( + 48 |+ (("parenthesized path call") +51 49 | # Comment between closing parentheses + 50 |+), +52 51 | ) +53 52 | +54 53 | Path( + +PTH201.py:55:5: PTH201 [*] Do not pass the current directory explicitly to `Path` + | +54 | Path( +55 | '' # Comment before comma + | ^^ PTH201 +56 | , +57 | ) / "multiple" / ( + | + = help: Remove the current directory argument + +ℹ Unsafe fix +52 52 | ) +53 53 | +54 54 | Path( +55 |- '' # Comment before comma + 55 |+ "multiple" # Comment before comma +56 56 | , +57 |-) / "multiple" / ( + 57 |+) / ( +58 58 | "frag" # Comment +59 59 | 'ment' +60 60 | )