Skip to content

Commit d0bcf56

Browse files
authored
Improve diff rendering for notebooks (#20036)
## Summary As noted in a code TODO, our `Diff` rendering code previously didn't have any special handling for notebooks. This was particularly obvious when the diffs were rendered right next to the corresponding diagnostic because the diagnostic used cell-based line numbers, while the diff was still using line numbers from the concatenated source. This PR updates the diff rendering to handle notebooks too. The main improvements shown in the example below are: - Line numbers are now remapped to be relative to their cell - Context lines from other cells are suppressed ``` error[unused-import][*]: `math` imported but unused --> notebook.ipynb:cell 2:2:8 | 1 | # cell 2 2 | import math | ^^^^ 3 | 4 | print('hello world') | help: Remove unused import: `math` ℹ Safe fix 1 1 | # cell 2 2 |-import math 3 2 | 4 3 | print('hello world') ``` I tried a few different approaches here before finally just splitting the notebook into separate text ranges by cell and diffing each one separately. It seems to work and passes all of our tests, but I don't know if it's actually enforced anywhere that a single edit doesn't span cells. Such an edit would silently be dropped right now since it would fail the `contains_range` check. I also feel like I may have overlooked an existing way to partition a file into cells like this. ## Test Plan Existing notebook tests, plus a new one in `ruff_db`
1 parent f9bbee3 commit d0bcf56

File tree

8 files changed

+274
-118
lines changed

8 files changed

+274
-118
lines changed

crates/ruff_db/src/diagnostic/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,11 @@ impl Diagnostic {
325325
self.inner.fix.as_ref()
326326
}
327327

328+
#[cfg(test)]
329+
pub(crate) fn fix_mut(&mut self) -> Option<&mut Fix> {
330+
Arc::make_mut(&mut self.inner).fix.as_mut()
331+
}
332+
328333
/// Set the fix for this diagnostic.
329334
pub fn set_fix(&mut self, fix: Fix) {
330335
debug_assert!(

crates/ruff_db/src/diagnostic/render.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2622,6 +2622,13 @@ watermelon
26222622
self.config = config;
26232623
}
26242624

2625+
/// Show a diff for the fix when rendering.
2626+
pub(super) fn show_fix_diff(&mut self, yes: bool) {
2627+
let mut config = std::mem::take(&mut self.config);
2628+
config = config.show_fix_diff(yes);
2629+
self.config = config;
2630+
}
2631+
26252632
/// The lowest fix applicability to show when rendering.
26262633
pub(super) fn fix_applicability(&mut self, applicability: Applicability) {
26272634
let mut config = std::mem::take(&mut self.config);

crates/ruff_db/src/diagnostic/render/full.rs

Lines changed: 213 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ use std::borrow::Cow;
22
use std::num::NonZeroUsize;
33

44
use anstyle::Style;
5+
use ruff_notebook::NotebookIndex;
56
use similar::{ChangeTag, TextDiff};
67

78
use ruff_annotate_snippets::Renderer as AnnotateRenderer;
89
use ruff_diagnostics::{Applicability, Fix};
910
use ruff_source_file::OneIndexed;
10-
use ruff_text_size::{Ranged, TextRange, TextSize};
11+
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
1112

1213
use crate::diagnostic::render::{FileResolver, Resolved};
1314
use crate::diagnostic::stylesheet::{DiagnosticStylesheet, fmt_styled};
@@ -81,6 +82,7 @@ impl<'a> FullRenderer<'a> {
8182
struct 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)]
255299
mod 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

Comments
 (0)