@@ -2,12 +2,13 @@ use std::borrow::Cow;
22use std:: num:: NonZeroUsize ;
33
44use anstyle:: Style ;
5+ use ruff_notebook:: NotebookIndex ;
56use similar:: { ChangeTag , TextDiff } ;
67
78use ruff_annotate_snippets:: Renderer as AnnotateRenderer ;
89use ruff_diagnostics:: { Applicability , Fix } ;
910use ruff_source_file:: OneIndexed ;
10- use ruff_text_size:: { Ranged , TextRange , TextSize } ;
11+ use ruff_text_size:: { Ranged , TextLen , TextRange , TextSize } ;
1112
1213use crate :: diagnostic:: render:: { FileResolver , Resolved } ;
1314use crate :: diagnostic:: stylesheet:: { DiagnosticStylesheet , fmt_styled} ;
@@ -81,6 +82,7 @@ impl<'a> FullRenderer<'a> {
8182struct Diff < ' a > {
8283 fix : & ' a Fix ,
8384 diagnostic_source : DiagnosticSource ,
85+ notebook_index : Option < NotebookIndex > ,
8486 stylesheet : & ' a DiagnosticStylesheet ,
8587}
8688
@@ -90,12 +92,11 @@ impl<'a> Diff<'a> {
9092 stylesheet : & ' a DiagnosticStylesheet ,
9193 resolver : & ' a dyn FileResolver ,
9294 ) -> Option < Diff < ' a > > {
95+ let file = & diagnostic. primary_span_ref ( ) ?. file ;
9396 Some ( Diff {
9497 fix : diagnostic. fix ( ) ?,
95- diagnostic_source : diagnostic
96- . primary_span_ref ( ) ?
97- . file
98- . diagnostic_source ( resolver) ,
98+ diagnostic_source : file. diagnostic_source ( resolver) ,
99+ notebook_index : resolver. notebook_index ( file) ,
99100 stylesheet,
100101 } )
101102 }
@@ -106,19 +107,24 @@ impl std::fmt::Display for Diff<'_> {
106107 let source_code = self . diagnostic_source . as_source_code ( ) ;
107108 let source_text = source_code. text ( ) ;
108109
109- // TODO(dhruvmanila): Add support for Notebook cells once it's user-facing
110- let mut output = String :: with_capacity ( source_text. len ( ) ) ;
111- let mut last_end = TextSize :: default ( ) ;
112-
113- for edit in self . fix . edits ( ) {
114- output. push_str ( source_code. slice ( TextRange :: new ( last_end, edit. start ( ) ) ) ) ;
115- output. push_str ( edit. content ( ) . unwrap_or_default ( ) ) ;
116- last_end = edit. end ( ) ;
117- }
118-
119- output. push_str ( & source_text[ usize:: from ( last_end) ..] ) ;
120-
121- let diff = TextDiff :: from_lines ( source_text, & output) ;
110+ // Partition the source code into end offsets for each cell. If `self.notebook_index` is
111+ // `None`, indicating a regular script file, all the lines will be in one "cell" under the
112+ // `None` key.
113+ let cells = if let Some ( notebook_index) = & self . notebook_index {
114+ let mut last_cell = OneIndexed :: MIN ;
115+ let mut cells: Vec < ( Option < OneIndexed > , TextSize ) > = Vec :: new ( ) ;
116+ for ( row, cell) in notebook_index. iter ( ) {
117+ if cell != last_cell {
118+ let offset = source_code. line_start ( row) ;
119+ cells. push ( ( Some ( last_cell) , offset) ) ;
120+ last_cell = cell;
121+ }
122+ }
123+ cells. push ( ( Some ( last_cell) , source_text. text_len ( ) ) ) ;
124+ cells
125+ } else {
126+ vec ! [ ( None , source_text. text_len( ) ) ]
127+ } ;
122128
123129 let message = match self . fix . applicability ( ) {
124130 // TODO(zanieb): Adjust this messaging once it's user-facing
@@ -133,59 +139,97 @@ impl std::fmt::Display for Diff<'_> {
133139 // tests, which is the only place these are currently used.
134140 writeln ! ( f, "ℹ {}" , fmt_styled( message, self . stylesheet. separator) ) ?;
135141
136- let ( largest_old, largest_new) = diff
137- . ops ( )
138- . last ( )
139- . map ( |op| ( op. old_range ( ) . start , op. new_range ( ) . start ) )
140- . unwrap_or_default ( ) ;
142+ let mut last_end = TextSize :: ZERO ;
143+ for ( cell, offset) in cells {
144+ let range = TextRange :: new ( last_end, offset) ;
145+ last_end = offset;
146+ let input = source_code. slice ( range) ;
147+
148+ let mut output = String :: with_capacity ( input. len ( ) ) ;
149+ let mut last_end = range. start ( ) ;
150+
151+ let mut applied = 0 ;
152+ for edit in self . fix . edits ( ) {
153+ if range. contains_range ( edit. range ( ) ) {
154+ output. push_str ( source_code. slice ( TextRange :: new ( last_end, edit. start ( ) ) ) ) ;
155+ output. push_str ( edit. content ( ) . unwrap_or_default ( ) ) ;
156+ last_end = edit. end ( ) ;
157+ applied += 1 ;
158+ }
159+ }
141160
142- let digit_with = OneIndexed :: from_zero_indexed ( largest_new. max ( largest_old) ) . digits ( ) ;
161+ // No edits were applied, so there's no need to diff.
162+ if applied == 0 {
163+ continue ;
164+ }
165+
166+ output. push_str ( & source_text[ usize:: from ( last_end) ..usize:: from ( range. end ( ) ) ] ) ;
167+
168+ let diff = TextDiff :: from_lines ( input, & output) ;
169+
170+ let ( largest_old, largest_new) = diff
171+ . ops ( )
172+ . last ( )
173+ . map ( |op| ( op. old_range ( ) . start , op. new_range ( ) . start ) )
174+ . unwrap_or_default ( ) ;
175+
176+ let digit_with = OneIndexed :: from_zero_indexed ( largest_new. max ( largest_old) ) . digits ( ) ;
143177
144- for ( idx, group) in diff. grouped_ops ( 3 ) . iter ( ) . enumerate ( ) {
145- if idx > 0 {
146- writeln ! ( f, "{:-^1$}" , "-" , 80 ) ?;
178+ if let Some ( cell) = cell {
179+ // Room for 2 digits, 2 x 1 space before each digit, 1 space, and 1 `|`. This
180+ // centers the three colons on the pipe.
181+ writeln ! ( f, "{:>1$} cell {cell}" , ":::" , 2 * digit_with. get( ) + 4 ) ?;
147182 }
148- for op in group {
149- for change in diff. iter_inline_changes ( op) {
150- let sign = match change. tag ( ) {
151- ChangeTag :: Delete => "-" ,
152- ChangeTag :: Insert => "+" ,
153- ChangeTag :: Equal => " " ,
154- } ;
155-
156- let line_style = LineStyle :: from ( change. tag ( ) , self . stylesheet ) ;
157-
158- let old_index = change. old_index ( ) . map ( OneIndexed :: from_zero_indexed) ;
159- let new_index = change. new_index ( ) . map ( OneIndexed :: from_zero_indexed) ;
160-
161- write ! (
162- f,
163- "{} {} |{}" ,
164- Line {
165- index: old_index,
166- width: digit_with
167- } ,
168- Line {
169- index: new_index,
170- width: digit_with
171- } ,
172- fmt_styled( line_style. apply_to( sign) , self . stylesheet. emphasis) ,
173- ) ?;
174-
175- for ( emphasized, value) in change. iter_strings_lossy ( ) {
176- let value = show_nonprinting ( & value) ;
177- if emphasized {
178- write ! (
179- f,
180- "{}" ,
181- fmt_styled( line_style. apply_to( & value) , self . stylesheet. underline)
182- ) ?;
183- } else {
184- write ! ( f, "{}" , line_style. apply_to( & value) ) ?;
183+
184+ for ( idx, group) in diff. grouped_ops ( 3 ) . iter ( ) . enumerate ( ) {
185+ if idx > 0 {
186+ writeln ! ( f, "{:-^1$}" , "-" , 80 ) ?;
187+ }
188+ for op in group {
189+ for change in diff. iter_inline_changes ( op) {
190+ let sign = match change. tag ( ) {
191+ ChangeTag :: Delete => "-" ,
192+ ChangeTag :: Insert => "+" ,
193+ ChangeTag :: Equal => " " ,
194+ } ;
195+
196+ let line_style = LineStyle :: from ( change. tag ( ) , self . stylesheet ) ;
197+
198+ let old_index = change. old_index ( ) . map ( OneIndexed :: from_zero_indexed) ;
199+ let new_index = change. new_index ( ) . map ( OneIndexed :: from_zero_indexed) ;
200+
201+ write ! (
202+ f,
203+ "{} {} |{}" ,
204+ Line {
205+ index: old_index,
206+ width: digit_with,
207+ } ,
208+ Line {
209+ index: new_index,
210+ width: digit_with,
211+ } ,
212+ fmt_styled( line_style. apply_to( sign) , self . stylesheet. emphasis) ,
213+ ) ?;
214+
215+ for ( emphasized, value) in change. iter_strings_lossy ( ) {
216+ let value = show_nonprinting ( & value) ;
217+ if emphasized {
218+ write ! (
219+ f,
220+ "{}" ,
221+ fmt_styled(
222+ line_style. apply_to( & value) ,
223+ self . stylesheet. underline
224+ )
225+ ) ?;
226+ } else {
227+ write ! ( f, "{}" , line_style. apply_to( & value) ) ?;
228+ }
229+ }
230+ if change. missing_newline ( ) {
231+ writeln ! ( f) ?;
185232 }
186- }
187- if change. missing_newline ( ) {
188- writeln ! ( f) ?;
189233 }
190234 }
191235 }
@@ -253,7 +297,7 @@ fn show_nonprinting(s: &str) -> Cow<'_, str> {
253297
254298#[ cfg( test) ]
255299mod tests {
256- use ruff_diagnostics:: Applicability ;
300+ use ruff_diagnostics:: { Applicability , Fix } ;
257301 use ruff_text_size:: { TextLen , TextRange , TextSize } ;
258302
259303 use crate :: diagnostic:: {
@@ -654,6 +698,107 @@ print()
654698 " ) ;
655699 }
656700
701+ /// Test that we remap notebook cell line numbers in the diff as well as the main diagnostic.
702+ #[ test]
703+ fn notebook_output_with_diff ( ) {
704+ let ( mut env, diagnostics) = create_notebook_diagnostics ( DiagnosticFormat :: Full ) ;
705+ env. show_fix_diff ( true ) ;
706+ insta:: assert_snapshot!( env. render_diagnostics( & diagnostics) , @r"
707+ error[unused-import][*]: `os` imported but unused
708+ --> notebook.ipynb:cell 1:2:8
709+ |
710+ 1 | # cell 1
711+ 2 | import os
712+ | ^^
713+ |
714+ help: Remove unused import: `os`
715+
716+ ℹ Safe fix
717+ ::: cell 1
718+ 1 1 | # cell 1
719+ 2 |-import os
720+
721+ error[unused-import][*]: `math` imported but unused
722+ --> notebook.ipynb:cell 2:2:8
723+ |
724+ 1 | # cell 2
725+ 2 | import math
726+ | ^^^^
727+ 3 |
728+ 4 | print('hello world')
729+ |
730+ help: Remove unused import: `math`
731+
732+ ℹ Safe fix
733+ ::: cell 2
734+ 1 1 | # cell 2
735+ 2 |-import math
736+ 3 2 |
737+ 4 3 | print('hello world')
738+
739+ error[unused-variable]: Local variable `x` is assigned to but never used
740+ --> notebook.ipynb:cell 3:4:5
741+ |
742+ 2 | def foo():
743+ 3 | print()
744+ 4 | x = 1
745+ | ^
746+ |
747+ help: Remove assignment to unused variable `x`
748+
749+ ℹ Unsafe fix
750+ ::: cell 3
751+ 1 1 | # cell 3
752+ 2 2 | def foo():
753+ 3 3 | print()
754+ 4 |- x = 1
755+ 5 4 |
756+ " ) ;
757+ }
758+
759+ #[ test]
760+ fn notebook_output_with_diff_spanning_cells ( ) {
761+ let ( mut env, mut diagnostics) = create_notebook_diagnostics ( DiagnosticFormat :: Full ) ;
762+ env. show_fix_diff ( true ) ;
763+
764+ // Move all of the edits from the later diagnostics to the first diagnostic to simulate a
765+ // single diagnostic with edits in different cells.
766+ let mut diagnostic = diagnostics. swap_remove ( 0 ) ;
767+ let fix = diagnostic. fix_mut ( ) . unwrap ( ) ;
768+ let mut edits = fix. edits ( ) . to_vec ( ) ;
769+ for diag in diagnostics {
770+ edits. extend_from_slice ( diag. fix ( ) . unwrap ( ) . edits ( ) ) ;
771+ }
772+ * fix = Fix :: unsafe_edits ( edits. remove ( 0 ) , edits) ;
773+
774+ insta:: assert_snapshot!( env. render( & diagnostic) , @r"
775+ error[unused-import]: `os` imported but unused
776+ --> notebook.ipynb:cell 1:2:8
777+ |
778+ 1 | # cell 1
779+ 2 | import os
780+ | ^^
781+ |
782+ help: Remove unused import: `os`
783+
784+ ℹ Unsafe fix
785+ ::: cell 1
786+ 1 1 | # cell 1
787+ 2 |-import os
788+ ::: cell 2
789+ 1 1 | # cell 2
790+ 2 |-import math
791+ 3 2 |
792+ 4 3 | print('hello world')
793+ ::: cell 3
794+ 1 1 | # cell 3
795+ 2 2 | def foo():
796+ 3 3 | print()
797+ 4 |- x = 1
798+ 5 4 |
799+ " ) ;
800+ }
801+
657802 /// Carriage return (`\r`) is a valid line-ending in Python, so we should normalize this to a
658803 /// line feed (`\n`) for rendering. Otherwise we report a single long line for this case.
659804 #[ test]
0 commit comments