1+ use ruff_diagnostics:: { Applicability , Edit , Fix } ;
12use 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+ } ;
47use ruff_python_codegen:: Generator ;
58use ruff_text_size:: { Ranged , TextRange } ;
69
7- use crate :: Violation ;
810use crate :: checkers:: ast:: Checker ;
911use crate :: fix:: snippet:: SourceCodeSnippet ;
10-
12+ use crate :: importer :: ImportRequest ;
1113use 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
4449impl 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.
7287struct ReadMatcher < ' a , ' b > {
7388 checker : & ' a Checker < ' b > ,
7489 candidates : Vec < FileOpen < ' a > > ,
90+ with_stmt : & ' a ast:: StmtWith ,
7591}
7692
7793impl < ' 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