[PATCH v4] cmdutil: add within-line color diff capacity

matthieu.laneuville at octobus.net matthieu.laneuville at octobus.net
Sat Nov 25 08:27:21 EST 2017


# HG changeset patch
# User Matthieu Laneuville <matthieu.laneuville at octobus.net>
# Date 1508944418 -32400
#      Thu Oct 26 00:13:38 2017 +0900
# Node ID bab248bd389941e034b002aed0e29b211b6995cf
# Parent  f56a30b844aa91eecd4e31b1fbc291d9d24973b3
# EXP-Topic inline-diff
cmdutil: add within-line color diff capacity

The `diff' command usually writes deletion in red and insertions in green. This
patch adds within-line colors, to highlight which part of the lines differ.
Lines to compare are decided based on their similarity ratio, as computed by
difflib SequenceMatcher, with an arbitrary threshold (0.7) to decide at which
point two lines are considered entirely different (therefore no inline-diff
required).

The current implementation is kept behind an experimental flag in order to test
the effect on performance. In order to activate it, set inline-color-diff to
true in [experimental].

diff -r f56a30b844aa -r bab248bd3899 mercurial/cmdutil.py
--- a/mercurial/cmdutil.py	Sat Oct 28 17:50:25 2017 +0530
+++ b/mercurial/cmdutil.py	Thu Oct 26 00:13:38 2017 +0900
@@ -7,6 +7,7 @@
 
 from __future__ import absolute_import
 
+import difflib
 import errno
 import itertools
 import os
@@ -1513,6 +1514,11 @@ def diffordiffstat(ui, repo, diffopts, n
                 ui.warn(_('warning: %s not inside relative root %s\n') % (
                     match.uipath(matchroot), uirelroot))
 
+    store = {
+        'diff.inserted': [],
+        'diff.deleted': []
+    }
+    status = False
     if stat:
         diffopts = diffopts.copy(context=0)
         width = 80
@@ -1529,7 +1535,31 @@ def diffordiffstat(ui, repo, diffopts, n
                                          changes, diffopts, prefix=prefix,
                                          relroot=relroot,
                                          hunksfilterfn=hunksfilterfn):
-            write(chunk, label=label)
+
+            if not ui.configbool("experimental", "inline-color-diff"):
+                write(chunk, label=label)
+                continue
+
+            # Each deleted/inserted chunk is followed by an EOL chunk with ''
+            # label. The 'status' flag helps us grab that second line.
+            if label in ['diff.deleted', 'diff.inserted'] or status:
+                if status:
+                    store[status].append(chunk)
+                    status = False
+                else:
+                    store[label].append(chunk)
+                    status = label
+                continue
+
+            if store['diff.inserted'] or store['diff.deleted']:
+                for line, l in _chunkdiff(store):
+                    write(line, label=l)
+
+                store['diff.inserted'] = []
+                store['diff.deleted'] = []
+
+            if chunk:
+                write(chunk, label=label)
 
     if listsubrepos:
         ctx1 = repo[node1]
@@ -1548,6 +1578,66 @@ def diffordiffstat(ui, repo, diffopts, n
             sub.diff(ui, diffopts, tempnode2, submatch, changes=changes,
                      stat=stat, fp=fp, prefix=prefix)
 
+def _chunkdiff(store):
+    '''Returns a (line, label) iterator over a corresponding deletion and
+       insertion set. The set has to be considered as a whole in order to match
+       lines and perform inline coloring.
+    '''
+    def chunkiterator(list1, list2, direction):
+        '''For each string in list1, finds matching string in list2 and returns
+           an iterator over their differences.
+        '''
+        used = []
+        for a in list1:
+            done = False
+            for i, b in enumerate(list2):
+                if done or i in used:
+                    continue
+                if difflib.SequenceMatcher(None, a, b).ratio() > 0.7:
+                    buff = _inlinediff(a, b, direction=direction)
+                    for line in buff:
+                        yield (line[1], line[0])
+                    done = True
+                    used.append(i) # insure lines in b can be matched only once
+            if not done:
+                yield (a, 'diff.' + direction)
+
+    insert = store['diff.inserted']
+    delete = store['diff.deleted']
+    return itertools.chain(chunkiterator(delete, insert, 'deleted'),
+                           chunkiterator(insert, delete, 'inserted'))
+
+def _inlinediff(from_string, to_string, direction):
+    '''Perform string diff to highlight specific changes.'''
+    direction_skip = '+?' if direction == 'deleted' else '-?'
+    if direction == 'deleted':
+        to_string, from_string = from_string, to_string
+
+    # buffer required to remove last space, there may be smarter ways to do this
+    buff = []
+
+    # we never want to higlight the leading +-
+    if direction == 'deleted' and to_string.startswith('-'):
+        buff.append(('diff.deleted', '-'))
+        to_string = to_string[1:]
+        from_string = from_string[1:]
+    elif direction == 'inserted' and from_string.startswith('+'):
+        buff.append(('diff.inserted', '+'))
+        to_string = to_string[1:]
+        from_string = from_string[1:]
+
+    s = difflib.ndiff(to_string.split(' '), from_string.split(' '))
+    for line in s:
+        if line[0] in direction_skip:
+            continue
+        l = 'diff.' + direction + '.highlight'
+        if line[0] in ' ': # unchanged parts
+            l = 'diff.' + direction
+        buff.append((l, line[2:] + ' '))
+
+    buff[-1] = (buff[-1][0], buff[-1][1].strip(' '))
+    return buff
+
 def _changesetlabels(ctx):
     labels = ['log.changeset', 'changeset.%s' % ctx.phasestr()]
     if ctx.obsolete():
diff -r f56a30b844aa -r bab248bd3899 mercurial/color.py
--- a/mercurial/color.py	Sat Oct 28 17:50:25 2017 +0530
+++ b/mercurial/color.py	Thu Oct 26 00:13:38 2017 +0900
@@ -87,12 +87,14 @@ except ImportError:
     'branches.inactive': 'none',
     'diff.changed': 'white',
     'diff.deleted': 'red',
+    'diff.deleted.highlight': 'red bold underline',
     'diff.diffline': 'bold',
     'diff.extended': 'cyan bold',
     'diff.file_a': 'red bold',
     'diff.file_b': 'green bold',
     'diff.hunk': 'magenta',
     'diff.inserted': 'green',
+    'diff.inserted.highlight': 'green bold underline',
     'diff.tab': '',
     'diff.trailingwhitespace': 'bold red_background',
     'changeset.public': '',
diff -r f56a30b844aa -r bab248bd3899 mercurial/configitems.py
--- a/mercurial/configitems.py	Sat Oct 28 17:50:25 2017 +0530
+++ b/mercurial/configitems.py	Thu Oct 26 00:13:38 2017 +0900
@@ -388,6 +388,9 @@ coreconfigitem('experimental', 'evolutio
 coreconfigitem('experimental', 'evolution.track-operation',
     default=True,
 )
+coreconfigitem('experimental', 'inline-color-diff',
+    default=False,
+)
 coreconfigitem('experimental', 'maxdeltachainspan',
     default=-1,
 )
diff -r f56a30b844aa -r bab248bd3899 tests/test-diff-color.t
--- a/tests/test-diff-color.t	Sat Oct 28 17:50:25 2017 +0530
+++ b/tests/test-diff-color.t	Thu Oct 26 00:13:38 2017 +0900
@@ -259,3 +259,95 @@ test tabs
   \x1b[0;32m+\x1b[0m\x1b[0;1;35m	\x1b[0m\x1b[0;32mall\x1b[0m\x1b[0;1;35m		\x1b[0m\x1b[0;32mtabs\x1b[0m\x1b[0;1;41m	\x1b[0m (esc)
 
   $ cd ..
+
+test inline color diff
+
+  $ hg init inline
+  $ cd inline
+  $ cat > file1 << EOF
+  > this is the first line
+  > this is the second line
+  >     third line starts with space
+  > + starts with a plus sign
+  > 
+  > this line won't change
+  > 
+  > two lines are going to
+  > be changed into three!
+  > 
+  > three of those lines will
+  > collapse onto one
+  > (to see if it works)
+  > EOF
+  $ hg add file1
+  $ hg ci -m 'commit'
+  $ cat > file1 << EOF
+  > that is the first paragraph
+  >     this is the second line
+  > third line starts with space
+  > - starts with a minus sign
+  > 
+  > this line won't change
+  > 
+  > two lines are going to
+  > (entirely magically,
+  >  assuming this works)
+  > be changed into four!
+  > 
+  > three of those lines have
+  > collapsed onto one
+  > EOF
+  $ hg diff --config experimental.inline-color-diff=False
+  \x1b[0;1mdiff --git a/file1 b/file1\x1b[0m (esc)
+  \x1b[0;31;1m--- a/file1\x1b[0m (esc)
+  \x1b[0;32;1m+++ b/file1\x1b[0m (esc)
+  \x1b[0;35m@@ -1,13 +1,14 @@\x1b[0m (esc)
+  \x1b[0;31m-this is the first line\x1b[0m (esc)
+  \x1b[0;31m-this is the second line\x1b[0m (esc)
+  \x1b[0;31m-    third line starts with space\x1b[0m (esc)
+  \x1b[0;31m-+ starts with a plus sign\x1b[0m (esc)
+  \x1b[0;32m+that is the first paragraph\x1b[0m (esc)
+  \x1b[0;32m+    this is the second line\x1b[0m (esc)
+  \x1b[0;32m+third line starts with space\x1b[0m (esc)
+  \x1b[0;32m+- starts with a minus sign\x1b[0m (esc)
+   
+   this line won't change
+   
+   two lines are going to
+  \x1b[0;31m-be changed into three!\x1b[0m (esc)
+  \x1b[0;32m+(entirely magically,\x1b[0m (esc)
+  \x1b[0;32m+ assuming this works)\x1b[0m (esc)
+  \x1b[0;32m+be changed into four!\x1b[0m (esc)
+   
+  \x1b[0;31m-three of those lines will\x1b[0m (esc)
+  \x1b[0;31m-collapse onto one\x1b[0m (esc)
+  \x1b[0;31m-(to see if it works)\x1b[0m (esc)
+  \x1b[0;32m+three of those lines have\x1b[0m (esc)
+  \x1b[0;32m+collapsed onto one\x1b[0m (esc)
+  $ hg diff --config experimental.inline-color-diff=True
+  \x1b[0;1mdiff --git a/file1 b/file1\x1b[0m (esc)
+  \x1b[0;31;1m--- a/file1\x1b[0m (esc)
+  \x1b[0;32;1m+++ b/file1\x1b[0m (esc)
+  \x1b[0;35m@@ -1,13 +1,14 @@\x1b[0m (esc)
+  \x1b[0;31m-\x1b[0m\x1b[0;31mthis \x1b[0m\x1b[0;31mis \x1b[0m\x1b[0;31mthe \x1b[0m\x1b[0;31;1;4mfirst \x1b[0m\x1b[0;31mline\x1b[0m (esc)
+  \x1b[0;31m-this is the second line\x1b[0m (esc)
+  \x1b[0;31m-\x1b[0m\x1b[0;31;1;4m \x1b[0m\x1b[0;31;1;4m \x1b[0m\x1b[0;31;1;4m \x1b[0m\x1b[0;31;1;4m \x1b[0m\x1b[0;31mthird \x1b[0m\x1b[0;31mline \x1b[0m\x1b[0;31mstarts \x1b[0m\x1b[0;31mwith \x1b[0m\x1b[0;31mspace\x1b[0m (esc)
+  \x1b[0;31m-\x1b[0m\x1b[0;31;1;4m+ \x1b[0m\x1b[0;31mstarts \x1b[0m\x1b[0;31mwith \x1b[0m\x1b[0;31ma \x1b[0m\x1b[0;31;1;4mplus \x1b[0m\x1b[0;31msign\x1b[0m (esc)
+  \x1b[0;32m+that is the first paragraph\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32;1;4m \x1b[0m\x1b[0;32;1;4m \x1b[0m\x1b[0;32;1;4m \x1b[0m\x1b[0;32;1;4m \x1b[0m\x1b[0;32mthis \x1b[0m\x1b[0;32mis \x1b[0m\x1b[0;32mthe \x1b[0m\x1b[0;32;1;4msecond \x1b[0m\x1b[0;32mline\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32mthird \x1b[0m\x1b[0;32mline \x1b[0m\x1b[0;32mstarts \x1b[0m\x1b[0;32mwith \x1b[0m\x1b[0;32mspace\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32;1;4m- \x1b[0m\x1b[0;32mstarts \x1b[0m\x1b[0;32mwith \x1b[0m\x1b[0;32ma \x1b[0m\x1b[0;32;1;4mminus \x1b[0m\x1b[0;32msign\x1b[0m (esc)
+   
+   this line won't change
+   
+   two lines are going to
+  \x1b[0;31m-\x1b[0m\x1b[0;31mbe \x1b[0m\x1b[0;31mchanged \x1b[0m\x1b[0;31minto \x1b[0m\x1b[0;31;1;4mthree!\x1b[0m (esc)
+  \x1b[0;32m+(entirely magically,\x1b[0m (esc)
+  \x1b[0;32m+ assuming this works)\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32mbe \x1b[0m\x1b[0;32mchanged \x1b[0m\x1b[0;32minto \x1b[0m\x1b[0;32;1;4mfour!\x1b[0m (esc)
+   
+  \x1b[0;31m-\x1b[0m\x1b[0;31mthree \x1b[0m\x1b[0;31mof \x1b[0m\x1b[0;31mthose \x1b[0m\x1b[0;31mlines \x1b[0m\x1b[0;31;1;4mwill\x1b[0m (esc)
+  \x1b[0;31m-\x1b[0m\x1b[0;31;1;4mcollapse \x1b[0m\x1b[0;31monto \x1b[0m\x1b[0;31mone\x1b[0m (esc)
+  \x1b[0;31m-(to see if it works)\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32mthree \x1b[0m\x1b[0;32mof \x1b[0m\x1b[0;32mthose \x1b[0m\x1b[0;32mlines \x1b[0m\x1b[0;32;1;4mhave\x1b[0m (esc)
+  \x1b[0;32m+\x1b[0m\x1b[0;32;1;4mcollapsed \x1b[0m\x1b[0;32monto \x1b[0m\x1b[0;32mone\x1b[0m (esc)


More information about the Mercurial-devel mailing list