1
1
import functools
2
2
import sys
3
+ from difflib import SequenceMatcher
3
4
from pathlib import Path
4
- from typing import Dict , List , cast , Iterable , Tuple , TypeVar
5
+ from typing import Dict , List , cast , Iterable , Tuple , TypeVar , Optional , Set
5
6
6
7
from rich .color import blend_rgb , Color
7
8
from rich .color_triplet import ColorTriplet
10
11
from rich .style import Style
11
12
from rich .syntax import Syntax
12
13
from rich .table import Table
14
+ from rich .text import Text
13
15
from unidiff import PatchSet
14
16
from unidiff .patch import PatchedFile , Hunk , Line
15
17
16
18
console = Console (force_terminal = True )
17
- T = TypeVar ( "T" )
19
+
18
20
MONOKAI_LIGHT_ACCENT = Color .from_rgb (62 , 64 , 54 )
19
21
MONOKAI_BACKGROUND = Color .from_rgb (red = 39 , green = 40 , blue = 34 )
20
22
DUNK_BACKGROUND = Color .from_rgb (red = 26 , green = 30 , blue = 22 )
21
23
24
+ T = TypeVar ("T" )
25
+
22
26
23
27
# TODO: Use rich pager here?
24
28
@@ -79,7 +83,8 @@ def main():
79
83
80
84
console .print ()
81
85
console .rule (
82
- f" [b]{ patch .path } [/] ([green]{ patch .added } additions[/], [red]{ patch .removed } removals[/]) { additional_context } " ,
86
+ f" [b]{ patch .path } [/] ([green]{ patch .added } additions[/], "
87
+ f"[red]{ patch .removed } removals[/]) { additional_context } " ,
83
88
style = "#45483d" ,
84
89
characters = "▁" ,
85
90
)
@@ -88,8 +93,8 @@ def main():
88
93
lexer = Syntax .guess_lexer (patch .path )
89
94
90
95
for is_first_hunk , hunk in loop_first (patch ):
91
- # Use difflib to examine differences between each link of the hunk
92
- # Target essentially means the additions/green text in the diff
96
+ # Use difflib to examine differences between each line of the hunk
97
+ # Target essentially means the additions/green text in diff
93
98
target_line_range = (
94
99
hunk .target_start ,
95
100
hunk .target_length + hunk .target_start - 1 ,
@@ -113,8 +118,6 @@ def main():
113
118
line_numbers = True ,
114
119
indent_guides = True ,
115
120
)
116
-
117
- # Gather information on source which lines were added/removed, so we can highlight them
118
121
source_removed_linenos = set ()
119
122
target_added_linenos = set ()
120
123
context_linenos = []
@@ -127,11 +130,13 @@ def main():
127
130
elif line .is_context :
128
131
context_linenos .append ((line .source_line_no , line .target_line_no ))
129
132
130
- # To ensure that lines are aligned on the left and right in the split diff, we need to add some padding above the lines
131
- # the amount of padding can be calculated by *changes* in the difference in offset between the source and target context
132
- # line numbers. When a change occurs, we note how much the change was, and that's how much padding we need to add. If the
133
- # change in source - target context line numbers is positive, we pad above the target. If it's negative, we pad above the
134
- # source line.
133
+ # To ensure that lines are aligned on the left and right in the split
134
+ # diff, we need to add some padding above the lines the amount of padding
135
+ # can be calculated by *changes* in the difference in offset between the
136
+ # source and target context line numbers. When a change occurs, we note
137
+ # how much the change was, and that's how much padding we need to add. If
138
+ # the change in source - target context line numbers is positive,
139
+ # we pad above the target. If it's negative, we pad above the source line.
135
140
source_lineno_to_padding = {}
136
141
target_lineno_to_padding = {}
137
142
@@ -149,29 +154,84 @@ def main():
149
154
target_lineno_to_padding [target_lineno ] = pad_amount
150
155
current_delta = delta
151
156
152
- # For inline diffing
153
- # if you have a contiguous streak of removal lines, followed by a contiguous streak of addition lines,
154
- # you can collect the removals into a string, collect the additions into a string, and diff two strings,
155
- # to find the locations in the line where things differ ABC
157
+ # Track which source and target lines are aligned and should be intraline
158
+ # diffed Work out row number of lines in each side of the diff. Row
159
+ # number is how far from the top of the syntax snippet we are. A line in
160
+ # the source and target with the same row numbers will be aligned in the
161
+ # diff (their line numbers in the source code may be different, though).
162
+ # There can be gaps in row numbers too, since sometimes we add padding
163
+ # above rows to ensure the source and target diffs are aligned with each
164
+ # other.
165
+
166
+ # Map row numbers to lines
167
+ source_lines_by_row_number = {}
168
+ target_lines_by_row_number = {}
169
+
170
+ accumulated_source_padding = 0
171
+ for i , line in enumerate (hunk .source_lines ()):
172
+ lineno = hunk .source_start + i
173
+ this_line_padding = source_lineno_to_padding .get (lineno , 0 )
174
+ accumulated_source_padding += this_line_padding
175
+ row_number = i + accumulated_source_padding
176
+ source_lines_by_row_number [row_number ] = line
177
+
178
+ accumulated_target_padding = 0
179
+ for i , line in enumerate (hunk .target_lines ()):
180
+ lineno = hunk .target_start + i
181
+ this_line_padding = target_lineno_to_padding .get (lineno , 0 )
182
+ accumulated_target_padding += this_line_padding
183
+ row_number = i + accumulated_target_padding
184
+ target_lines_by_row_number [row_number ] = line
185
+
186
+ row_number_to_deletion_ranges = {}
187
+ row_number_to_insertion_ranges = {}
188
+ # Collect intraline diff info for highlighting
189
+ for row_number , source_line in source_lines_by_row_number .items ():
190
+ target_line = target_lines_by_row_number .get (row_number )
191
+ are_diffable = (
192
+ source_line
193
+ and target_line
194
+ and source_line .is_removed
195
+ and target_line .is_added
196
+ )
197
+ if target_line and are_diffable :
198
+ matcher = SequenceMatcher (
199
+ None , source_line .value , target_line .value
200
+ )
201
+ opcodes = matcher .get_opcodes ()
202
+ ratio = matcher .ratio ()
203
+ if ratio > 0.66 : # 0.66 seems to work well
204
+ for tag , i1 , i2 , j1 , j2 in opcodes :
205
+ if tag == "delete" :
206
+ row_number_to_deletion_ranges [row_number ] = (i1 , i2 )
207
+ elif tag == "insert" :
208
+ row_number_to_insertion_ranges [row_number ] = (j1 , j2 )
209
+ elif tag == "replace" :
210
+ row_number_to_deletion_ranges [row_number ] = (i1 , i2 )
211
+ row_number_to_insertion_ranges [row_number ] = (j1 , j2 )
156
212
157
213
source_syntax_lines : List [List [Segment ]] = console .render_lines (
158
214
source_syntax
159
215
)
160
216
target_syntax_lines = console .render_lines (target_syntax )
161
217
162
- highlighted_source_lines = highlight_lines_in_hunk (
218
+ highlighted_source_lines = highlight_and_align_lines_in_hunk (
163
219
hunk .source_start ,
164
220
source_removed_linenos ,
165
221
source_syntax_lines ,
166
222
ColorTriplet (255 , 0 , 0 ),
167
223
source_lineno_to_padding ,
224
+ row_number_to_deletion_ranges ,
225
+ gutter_size = len (str (source_lineno_max )) + 2 ,
168
226
)
169
- highlighted_target_lines = highlight_lines_in_hunk (
227
+ highlighted_target_lines = highlight_and_align_lines_in_hunk (
170
228
hunk .target_start ,
171
229
target_added_linenos ,
172
230
target_syntax_lines ,
173
231
ColorTriplet (0 , 255 , 0 ),
174
232
target_lineno_to_padding ,
233
+ row_number_to_insertion_ranges ,
234
+ gutter_size = len (str (len (target_lines ) + 1 )) + 2 ,
175
235
)
176
236
177
237
table = Table .grid ()
@@ -190,30 +250,35 @@ def main():
190
250
)
191
251
console .rule (hunk_header , characters = "╲" , style = hunk_header_style )
192
252
console .print (table )
253
+
254
+ # TODO: File name indicator at bottom of file, if file diff is larger than terminal height.
193
255
console .rule (style = "#45483d" , characters = "▔" )
194
256
195
257
196
- def highlight_lines_in_hunk (
197
- start_lineno ,
198
- highlight_linenos ,
199
- syntax_lines ,
200
- blend_colour ,
258
+ def highlight_and_align_lines_in_hunk (
259
+ start_lineno : int ,
260
+ highlight_linenos : Set [ Optional [ int ]] ,
261
+ syntax_hunk_lines ,
262
+ blend_colour : ColorTriplet ,
201
263
lines_to_pad_above : Dict [int , int ],
264
+ highlight_ranges : Dict [int , Tuple [int , int ]],
265
+ gutter_size : int ,
202
266
):
203
267
highlighted_lines = []
204
- for line in syntax_lines :
205
- if len (line ) > 0 :
206
- text , style , control = line [0 ]
207
- line [0 ] = Segment (
208
- "▏" ,
209
- Style .from_color (
210
- color = MONOKAI_LIGHT_ACCENT , bgcolor = MONOKAI_BACKGROUND
211
- ),
212
- control ,
213
- )
214
-
215
- # Apply additional diff-related highlighting to lines
216
- for index , line in enumerate (syntax_lines ):
268
+ # TODO: Don't think this really asd
269
+ # for line in syntax_hunk_lines:
270
+ # if len(line) > 0:
271
+ # text, style, control = line[0]
272
+ # line[0] = Segment(
273
+ # "▏",
274
+ # Style.from_color(
275
+ # color=MONOKAI_LIGHT_ACCENT, bgcolor=MONOKAI_BACKGROUND
276
+ # ),
277
+ # control,
278
+ # )
279
+
280
+ # Apply diff-related highlighting to lines
281
+ for index , line in enumerate (syntax_hunk_lines ):
217
282
lineno = index + start_lineno
218
283
219
284
if lineno in highlight_linenos :
@@ -222,6 +287,7 @@ def highlight_lines_in_hunk(
222
287
for segment in line :
223
288
style : Style
224
289
text , style , control = segment
290
+
225
291
if style :
226
292
if style .bgcolor :
227
293
bgcolor_triplet = style .bgcolor .triplet
@@ -249,9 +315,6 @@ def highlight_lines_in_hunk(
249
315
color = new_color , bgcolor = new_bgcolor
250
316
)
251
317
updated_style = style + overlay_style
252
- if segment_number == 1 :
253
- updated_style += Style (italic = True )
254
-
255
318
new_line .append (Segment (text , updated_style , control ))
256
319
else :
257
320
new_line .append (segment )
@@ -261,7 +324,6 @@ def highlight_lines_in_hunk(
261
324
262
325
# Pad above the line if required
263
326
pad = lines_to_pad_above .get (lineno , 0 )
264
- # pad = 0
265
327
for i in range (pad ):
266
328
highlighted_lines .append (
267
329
[
@@ -271,6 +333,32 @@ def highlight_lines_in_hunk(
271
333
]
272
334
)
273
335
336
+ # Finally, apply the intraline diff highlighting for this line if required
337
+ highlight_range = highlight_ranges .get (index )
338
+ if highlight_range :
339
+ start , end = highlight_range
340
+ line_as_text = Text .assemble (
341
+ * ((text , style ) for text , style , control in new_line ), end = ""
342
+ )
343
+
344
+ intraline_bgcolor = Color .from_triplet (
345
+ blend_rgb_cached (
346
+ blend_colour , MONOKAI_BACKGROUND .triplet , cross_fade = 0.6
347
+ )
348
+ )
349
+ intraline_color = Color .from_triplet (
350
+ blend_rgb_cached (
351
+ intraline_bgcolor .triplet ,
352
+ Color .from_rgb (255 , 255 , 255 ).triplet ,
353
+ cross_fade = 0.8 ,
354
+ )
355
+ )
356
+ line_as_text .stylize (
357
+ Style .from_color (color = intraline_color , bgcolor = intraline_bgcolor ),
358
+ start = start + gutter_size + 1 ,
359
+ end = end + gutter_size + 1 ,
360
+ )
361
+ new_line = list (console .render (line_as_text ))
274
362
highlighted_lines .append (new_line )
275
363
return highlighted_lines
276
364
0 commit comments