1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
4
5import warnings
6from gettext import gettext as _
7from itertools import repeat, zip_longest
8from math import ceil
9from typing import Callable, Dict, Generator, Iterable, List, Optional, Tuple
10
11from kitty.cli_stub import DiffCLIOptions
12from kitty.fast_data_types import truncate_point_for_length, wcswidth
13from kitty.types import run_once
14from kitty.utils import ScreenSize
15
16from ..tui.images import ImageManager, can_display_images
17from .collect import (
18    Collection, Segment, data_for_path, highlights_for_path, is_image,
19    lines_for_path, path_name_map, sanitize
20)
21from .config import formats
22from .diff_speedup import split_with_highlights as _split_with_highlights
23from .patch import Chunk, Hunk, Patch
24
25
26class ImageSupportWarning(Warning):
27    pass
28
29
30@run_once
31def images_supported() -> bool:
32    ans = can_display_images()
33    if not ans:
34        warnings.warn('ImageMagick not found images cannot be displayed', ImageSupportWarning)
35    return ans
36
37
38class Ref:
39
40    def __setattr__(self, name: str, value: object) -> None:
41        raise AttributeError("can't set attribute")
42
43    def __repr__(self) -> str:
44        return '{}({})'.format(self.__class__.__name__, ', '.join(
45            '{}={}'.format(n, getattr(self, n)) for n in self.__slots__ if n != '_hash'))
46
47
48class LineRef(Ref):
49
50    __slots__ = ('src_line_number', 'wrapped_line_idx')
51    src_line_number: int
52    wrapped_line_idx: int
53
54    def __init__(self, sln: int, wli: int = 0) -> None:
55        object.__setattr__(self, 'src_line_number', sln)
56        object.__setattr__(self, 'wrapped_line_idx', wli)
57
58
59class Reference(Ref):
60
61    __slots__ = ('path', 'extra')
62    path: str
63    extra: Optional[LineRef]
64
65    def __init__(self, path: str, extra: Optional[LineRef] = None) -> None:
66        object.__setattr__(self, 'path', path)
67        object.__setattr__(self, 'extra', extra)
68
69
70class Line:
71
72    __slots__ = ('text', 'ref', 'is_change_start', 'image_data')
73
74    def __init__(
75        self,
76        text: str,
77        ref: Reference,
78        change_start: bool = False,
79        image_data: Optional[Tuple[Optional['ImagePlacement'], Optional['ImagePlacement']]] = None
80    ) -> None:
81        self.text = text
82        self.ref = ref
83        self.is_change_start = change_start
84        self.image_data = image_data
85
86
87def yield_lines_from(iterator: Iterable[str], reference: Reference, is_change_start: bool = True) -> Generator[Line, None, None]:
88    for text in iterator:
89        yield Line(text, reference, is_change_start)
90        is_change_start = False
91
92
93def human_readable(size: int, sep: str = ' ') -> str:
94    """ Convert a size in bytes into a human readable form """
95    divisor, suffix = 1, "B"
96    for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
97        if size < (1 << ((i + 1) * 10)):
98            divisor, suffix = (1 << (i * 10)), candidate
99            break
100    s = str(float(size)/divisor)
101    if s.find(".") > -1:
102        s = s[:s.find(".")+2]
103    if s.endswith('.0'):
104        s = s[:-2]
105    return s + sep + suffix
106
107
108def fit_in(text: str, count: int) -> str:
109    p = truncate_point_for_length(text, count)
110    if p >= len(text):
111        return text
112    if count > 1:
113        p = truncate_point_for_length(text, count - 1)
114    return text[:p] + '…'
115
116
117def fill_in(text: str, sz: int) -> str:
118    w = wcswidth(text)
119    if w < sz:
120        text += ' ' * (sz - w)
121    return text
122
123
124def place_in(text: str, sz: int) -> str:
125    return fill_in(fit_in(text, sz), sz)
126
127
128def format_func(which: str) -> Callable[[str], str]:
129    def formatted(text: str) -> str:
130        fmt = formats[which]
131        return '\x1b[' + fmt + 'm' + text + '\x1b[0m'
132    formatted.__name__ = which + '_format'
133    return formatted
134
135
136text_format = format_func('text')
137title_format = format_func('title')
138margin_format = format_func('margin')
139added_format = format_func('added')
140removed_format = format_func('removed')
141removed_margin_format = format_func('removed_margin')
142added_margin_format = format_func('added_margin')
143filler_format = format_func('filler')
144margin_filler_format = format_func('margin_filler')
145hunk_margin_format = format_func('hunk_margin')
146hunk_format = format_func('hunk')
147highlight_map = {'remove': ('removed_highlight', 'removed'), 'add': ('added_highlight', 'added')}
148
149
150def highlight_boundaries(ltype: str) -> Tuple[str, str]:
151    s, e = highlight_map[ltype]
152    start = '\x1b[' + formats[s] + 'm'
153    stop = '\x1b[' + formats[e] + 'm'
154    return start, stop
155
156
157def title_lines(left_path: Optional[str], right_path: Optional[str], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]:
158    m = ' ' * margin_size
159    left_name = path_name_map.get(left_path) if left_path else None
160    right_name = path_name_map.get(right_path) if right_path else None
161    if right_name and right_name != left_name:
162        n1 = fit_in(m + sanitize(left_name or ''), columns // 2 - margin_size)
163        n1 = place_in(n1, columns // 2)
164        n2 = fit_in(m + sanitize(right_name), columns // 2 - margin_size)
165        n2 = place_in(n2, columns // 2)
166        name = n1 + n2
167    else:
168        name = place_in(m + sanitize(left_name or ''), columns)
169    yield title_format(place_in(name, columns))
170    yield title_format('━' * columns)
171
172
173def binary_lines(path: Optional[str], other_path: Optional[str], columns: int, margin_size: int) -> Generator[str, None, None]:
174    template = _('Binary file: {}')
175    available_cols = columns // 2 - margin_size
176
177    def fl(path: str, fmt: Callable[[str], str]) -> str:
178        text = template.format(human_readable(len(data_for_path(path))))
179        text = place_in(text, available_cols)
180        return margin_format(' ' * margin_size) + fmt(text)
181
182    if path is None:
183        filler = render_diff_line('', '', 'filler', margin_size, available_cols)
184        assert other_path is not None
185        yield filler + fl(other_path, added_format)
186    elif other_path is None:
187        filler = render_diff_line('', '', 'filler', margin_size, available_cols)
188        yield fl(path, removed_format) + filler
189    else:
190        yield fl(path, removed_format) + fl(other_path, added_format)
191
192
193def split_to_size(line: str, width: int) -> Generator[str, None, None]:
194    if not line:
195        yield line
196    while line:
197        p = truncate_point_for_length(line, width)
198        yield line[:p]
199        line = line[p:]
200
201
202def truncate_points(line: str, width: int) -> Generator[int, None, None]:
203    pos = 0
204    sz = len(line)
205    while True:
206        pos = truncate_point_for_length(line, width, pos)
207        if pos < sz:
208            yield pos
209        else:
210            break
211
212
213def split_with_highlights(line: str, width: int, highlights: List, bg_highlight: Optional[Segment] = None) -> List:
214    truncate_pts = list(truncate_points(line, width))
215    return _split_with_highlights(line, truncate_pts, highlights, bg_highlight)
216
217
218margin_bg_map = {'filler': margin_filler_format, 'remove': removed_margin_format, 'add': added_margin_format, 'context': margin_format}
219text_bg_map = {'filler': filler_format, 'remove': removed_format, 'add': added_format, 'context': text_format}
220
221
222class DiffData:
223
224    def __init__(self, left_path: str, right_path: str, available_cols: int, margin_size: int):
225        self.left_path, self.right_path = left_path, right_path
226        self.available_cols = available_cols
227        self.margin_size = margin_size
228        self.left_lines, self.right_lines = map(lines_for_path, (left_path, right_path))
229        self.filler_line = render_diff_line('', '', 'filler', margin_size, available_cols)
230        self.left_filler_line = render_diff_line('', '', 'remove', margin_size, available_cols)
231        self.right_filler_line = render_diff_line('', '', 'add', margin_size, available_cols)
232        self.left_hdata = highlights_for_path(left_path)
233        self.right_hdata = highlights_for_path(right_path)
234
235    def left_highlights_for_line(self, line_num: int) -> List[Segment]:
236        if line_num < len(self.left_hdata):
237            return self.left_hdata[line_num]
238        return []
239
240    def right_highlights_for_line(self, line_num: int) -> List[Segment]:
241        if line_num < len(self.right_hdata):
242            return self.right_hdata[line_num]
243        return []
244
245
246def render_diff_line(number: Optional[str], text: str, ltype: str, margin_size: int, available_cols: int) -> str:
247    margin = margin_bg_map[ltype](place_in(number or '', margin_size))
248    content = text_bg_map[ltype](fill_in(text or '', available_cols))
249    return margin + content
250
251
252def render_diff_pair(
253    left_line_number: Optional[str], left: str, left_is_change: bool,
254    right_line_number: Optional[str], right: str, right_is_change: bool,
255    is_first: bool, margin_size: int, available_cols: int
256) -> str:
257    ltype = 'filler' if left_line_number is None else ('remove' if left_is_change else 'context')
258    rtype = 'filler' if right_line_number is None else ('add' if right_is_change else 'context')
259    return (
260            render_diff_line(left_line_number if is_first else None, left, ltype, margin_size, available_cols) +
261            render_diff_line(right_line_number if is_first else None, right, rtype, margin_size, available_cols)
262    )
263
264
265def hunk_title(hunk_num: int, hunk: Hunk, margin_size: int, available_cols: int) -> str:
266    m = hunk_margin_format(' ' * margin_size)
267    t = '@@ -{},{} +{},{} @@ {}'.format(hunk.left_start + 1, hunk.left_count, hunk.right_start + 1, hunk.right_count, hunk.title)
268    return m + hunk_format(place_in(t, available_cols))
269
270
271def render_half_line(
272    line_number: int,
273    line: str,
274    highlights: List,
275    ltype: str,
276    margin_size: int,
277    available_cols: int,
278    changed_center: Optional[Tuple[int, int]] = None
279) -> Generator[str, None, None]:
280    bg_highlight: Optional[Segment] = None
281    if changed_center is not None and changed_center[0]:
282        prefix_count, suffix_count = changed_center
283        line_sz = len(line)
284        if prefix_count + suffix_count < line_sz:
285            start, stop = highlight_boundaries(ltype)
286            seg = Segment(prefix_count, start)
287            seg.end = line_sz - suffix_count
288            seg.end_code = stop
289            bg_highlight = seg
290    if highlights or bg_highlight:
291        lines: Iterable[str] = split_with_highlights(line, available_cols, highlights, bg_highlight)
292    else:
293        lines = split_to_size(line, available_cols)
294    lnum = str(line_number + 1)
295    for line in lines:
296        yield render_diff_line(lnum, line, ltype, margin_size, available_cols)
297        lnum = ''
298
299
300def lines_for_chunk(data: DiffData, hunk_num: int, chunk: Chunk, chunk_num: int) -> Generator[Line, None, None]:
301    if chunk.is_context:
302        for i in range(chunk.left_count):
303            left_line_number = line_ref = chunk.left_start + i
304            right_line_number = chunk.right_start + i
305            highlights = data.left_highlights_for_line(left_line_number)
306            if highlights:
307                lines: Iterable[str] = split_with_highlights(data.left_lines[left_line_number], data.available_cols, highlights)
308            else:
309                lines = split_to_size(data.left_lines[left_line_number], data.available_cols)
310            left_line_number_s = str(left_line_number + 1)
311            right_line_number_s = str(right_line_number + 1)
312            for wli, text in enumerate(lines):
313                line = render_diff_line(left_line_number_s, text, 'context', data.margin_size, data.available_cols)
314                if right_line_number_s == left_line_number_s:
315                    r = line
316                else:
317                    r = render_diff_line(right_line_number_s, text, 'context', data.margin_size, data.available_cols)
318                ref = Reference(data.left_path, LineRef(line_ref, wli))
319                yield Line(line + r, ref)
320                left_line_number_s = right_line_number_s = ''
321    else:
322        common = min(chunk.left_count, chunk.right_count)
323        for i in range(max(chunk.left_count, chunk.right_count)):
324            ll: List[str] = []
325            rl: List[str] = []
326            if i < chunk.left_count:
327                rln = ref_ln = chunk.left_start + i
328                ll.extend(render_half_line(
329                    rln, data.left_lines[rln], data.left_highlights_for_line(rln),
330                    'remove', data.margin_size, data.available_cols,
331                    None if chunk.centers is None else chunk.centers[i]))
332                ref_path = data.left_path
333            if i < chunk.right_count:
334                rln = ref_ln = chunk.right_start + i
335                rl.extend(render_half_line(
336                    rln, data.right_lines[rln], data.right_highlights_for_line(rln),
337                    'add', data.margin_size, data.available_cols,
338                    None if chunk.centers is None else chunk.centers[i]))
339                ref_path = data.right_path
340            if i < common:
341                extra = len(ll) - len(rl)
342                if extra != 0:
343                    if extra < 0:
344                        x, fl = ll, data.left_filler_line
345                        extra = -extra
346                    else:
347                        x, fl = rl, data.right_filler_line
348                    x.extend(repeat(fl, extra))
349            else:
350                if ll:
351                    x, count = rl, len(ll)
352                else:
353                    x, count = ll, len(rl)
354                x.extend(repeat(data.filler_line, count))
355            for wli, (left_line, right_line) in enumerate(zip(ll, rl)):
356                ref = Reference(ref_path, LineRef(ref_ln, wli))
357                yield Line(left_line + right_line, ref, i == 0 and wli == 0)
358
359
360def lines_for_diff(left_path: str, right_path: str, hunks: Iterable[Hunk], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[Line, None, None]:
361    available_cols = columns // 2 - margin_size
362    data = DiffData(left_path, right_path, available_cols, margin_size)
363
364    for hunk_num, hunk in enumerate(hunks):
365        yield Line(hunk_title(hunk_num, hunk, margin_size, columns - margin_size), Reference(left_path, LineRef(hunk.left_start)))
366        for cnum, chunk in enumerate(hunk.chunks):
367            yield from lines_for_chunk(data, hunk_num, chunk, cnum)
368
369
370def all_lines(path: str, args: DiffCLIOptions, columns: int, margin_size: int, is_add: bool = True) -> Generator[Line, None, None]:
371    available_cols = columns // 2 - margin_size
372    ltype = 'add' if is_add else 'remove'
373    lines = lines_for_path(path)
374    filler = render_diff_line('', '', 'filler', margin_size, available_cols)
375    msg_written = False
376    hdata = highlights_for_path(path)
377
378    def highlights(num: int) -> List[Segment]:
379        return hdata[num] if num < len(hdata) else []
380
381    for line_number, line in enumerate(lines):
382        h = render_half_line(line_number, line, highlights(line_number), ltype, margin_size, available_cols)
383        for i, hl in enumerate(h):
384            ref = Reference(path, LineRef(line_number, i))
385            empty = filler
386            if not msg_written:
387                msg_written = True
388                empty = render_diff_line(
389                        '', _('This file was added') if is_add else _('This file was removed'),
390                        'filler', margin_size, available_cols)
391            text = (empty + hl) if is_add else (hl + empty)
392            yield Line(text, ref, line_number == 0 and i == 0)
393
394
395def rename_lines(path: str, other_path: str, args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]:
396    m = ' ' * margin_size
397    for line in split_to_size(_('The file {0} was renamed to {1}').format(
398            sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns - margin_size):
399        yield m + line
400
401
402class Image:
403
404    def __init__(self, image_id: int, width: int, height: int, margin_size: int, screen_size: ScreenSize) -> None:
405        self.image_id = image_id
406        self.width, self.height = width, height
407        self.rows = int(ceil(self.height / screen_size.cell_height))
408        self.columns = int(ceil(self.width / screen_size.cell_width))
409        self.margin_size = margin_size
410
411
412class ImagePlacement:
413
414    def __init__(self, image: Image, row: int) -> None:
415        self.image = image
416        self.row = row
417
418
419def render_image(
420    path: str,
421    is_left: bool,
422    available_cols: int, margin_size: int,
423    image_manager: ImageManager
424) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]:
425    lnum = 0
426    margin_fmt = removed_margin_format if is_left else added_margin_format
427    m = margin_fmt(' ' * margin_size)
428    fmt = removed_format if is_left else added_format
429
430    def yield_split(text: str) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]:
431        nonlocal lnum
432        for i, line in enumerate(split_to_size(text, available_cols)):
433            yield m + fmt(place_in(line, available_cols)), Reference(path, LineRef(lnum, i)), None
434        lnum += 1
435
436    try:
437        image_id, width, height = image_manager.send_image(path, available_cols - margin_size, image_manager.screen_size.rows - 2)
438    except Exception as e:
439        yield from yield_split(_('Failed to render image, with error:'))
440        yield from yield_split(' '.join(str(e).splitlines()))
441        return
442    meta = _('Dimensions: {0}x{1} pixels Size: {2}').format(
443            width, height, human_readable(len(data_for_path(path))))
444    yield from yield_split(meta)
445    bg_line = m + fmt(' ' * available_cols)
446    img = Image(image_id, width, height, margin_size, image_manager.screen_size)
447    for r in range(img.rows):
448        yield bg_line, Reference(path, LineRef(lnum)), ImagePlacement(img, r)
449        lnum += 1
450
451
452def image_lines(
453    left_path: Optional[str],
454    right_path: Optional[str],
455    columns: int,
456    margin_size: int,
457    image_manager: ImageManager
458) -> Generator[Line, None, None]:
459    available_cols = columns // 2 - margin_size
460    left_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(())
461    right_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(())
462    if left_path is not None:
463        left_lines = render_image(left_path, True, available_cols, margin_size, image_manager)
464    if right_path is not None:
465        right_lines = render_image(right_path, False, available_cols, margin_size, image_manager)
466    filler = ' ' * (available_cols + margin_size)
467    is_change_start = True
468    for left, right in zip_longest(left_lines, right_lines):
469        left_placement = right_placement = None
470        if left is None:
471            left = filler
472            right, ref, right_placement = right
473        elif right is None:
474            right = filler
475            left, ref, left_placement = left
476        else:
477            right, ref, right_placement = right
478            left, ref, left_placement = left
479        image_data = (left_placement, right_placement) if left_placement or right_placement else None
480        yield Line(left + right, ref, is_change_start, image_data)
481        is_change_start = False
482
483
484class RenderDiff:
485
486    margin_size: int = 0
487
488    def __call__(
489        self,
490        collection: Collection,
491        diff_map: Dict[str, Patch],
492        args: DiffCLIOptions,
493        columns: int,
494        image_manager: ImageManager
495    ) -> Generator[Line, None, None]:
496        largest_line_number = 0
497        for path, item_type, other_path in collection:
498            if item_type == 'diff':
499                patch = diff_map.get(path)
500                if patch is not None:
501                    largest_line_number = max(largest_line_number, patch.largest_line_number)
502
503        margin_size = self.margin_size = max(3, len(str(largest_line_number)) + 1)
504        last_item_num = len(collection) - 1
505
506        for i, (path, item_type, other_path) in enumerate(collection):
507            item_ref = Reference(path)
508            is_binary = isinstance(data_for_path(path), bytes)
509            if not is_binary and item_type == 'diff' and isinstance(data_for_path(other_path), bytes):
510                is_binary = True
511            is_img = is_binary and (is_image(path) or is_image(other_path)) and images_supported()
512            yield from yield_lines_from(title_lines(path, other_path, args, columns, margin_size), item_ref, False)
513            if item_type == 'diff':
514                if is_binary:
515                    if is_img:
516                        ans = image_lines(path, other_path, columns, margin_size, image_manager)
517                    else:
518                        ans = yield_lines_from(binary_lines(path, other_path, columns, margin_size), item_ref)
519                else:
520                    assert other_path is not None
521                    ans = lines_for_diff(path, other_path, diff_map[path], args, columns, margin_size)
522            elif item_type == 'add':
523                if is_binary:
524                    if is_img:
525                        ans = image_lines(None, path, columns, margin_size, image_manager)
526                    else:
527                        ans = yield_lines_from(binary_lines(None, path, columns, margin_size), item_ref)
528                else:
529                    ans = all_lines(path, args, columns, margin_size, is_add=True)
530            elif item_type == 'removal':
531                if is_binary:
532                    if is_img:
533                        ans = image_lines(path, None, columns, margin_size, image_manager)
534                    else:
535                        ans = yield_lines_from(binary_lines(path, None, columns, margin_size), item_ref)
536                else:
537                    ans = all_lines(path, args, columns, margin_size, is_add=False)
538            elif item_type == 'rename':
539                assert other_path is not None
540                ans = yield_lines_from(rename_lines(path, other_path, args, columns, margin_size), item_ref)
541            else:
542                raise ValueError('Unsupported item type: {}'.format(item_type))
543            yield from ans
544            if i < last_item_num:
545                yield Line('', item_ref)
546
547
548render_diff = RenderDiff()
549