diff: improve positioning of add/delete blocks in diffs

Some groups of added/deleted lines in diffs can be slid up or down, because lines at the edges of the group are not unique. Picking good shifts for such groups is not a matter of correctness but definitely has a big effect on aesthetics. For example, consider the following two diffs. The first is what standard Git emits: --- a/9c572b21dd090a1e5c5bb397053bf8043ffe7fb4:git-send-email.perl +++ b/6dcfa306f2b67b733a7eb2d7ded1bc9987809edb:git-send-email.perl @@ -231,6 +231,9 @@ if (!defined $initial_reply_to && $prompting) { } if (!$smtp_server) { + $smtp_server = $repo->config('sendemail.smtpserver'); +} +if (!$smtp_server) { foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) { if (-x $_) { $smtp_server = $_; The following diff is equivalent, but is obviously preferable from an aesthetic point of view: --- a/9c572b21dd090a1e5c5bb397053bf8043ffe7fb4:git-send-email.perl +++ b/6dcfa306f2b67b733a7eb2d7ded1bc9987809edb:git-send-email.perl @@ -230,6 +230,9 @@ if (!defined $initial_reply_to && $prompting) { $initial_reply_to =~ s/(^\s+|\s+$)//g; } +if (!$smtp_server) { + $smtp_server = $repo->config('sendemail.smtpserver'); +} if (!$smtp_server) { foreach (qw( /usr/sbin/sendmail /usr/lib/sendmail )) { if (-x $_) { This patch teaches Git to pick better positions for such "diff sliders" using heuristics that take the positions of nearby blank lines and the indentation of nearby lines into account. The existing Git code basically always shifts such "sliders" as far down in the file as possible. The only exception is when the slider can be aligned with a group of changed lines in the other file, in which case Git favors depicting the change as one add+delete block rather than one add and a slightly offset delete block. This naive algorithm often yields ugly diffs. Commit d634d61ed6 improved the situation somewhat by preferring to position add/delete groups to make their last line a blank line, when that is possible. This heuristic does more good than harm, but (1) it can only help if there are blank lines in the right places, and (2) always picks the last blank line, even if there are others that might be better. The end result is that it makes perhaps 1/3 as many errors as the default Git algorithm, but that still leaves a lot of ugly diffs. This commit implements a new and much better heuristic for picking optimal "slider" positions using the following approach: First observe that each hypothetical positioning of a diff slider introduces two splits: one between the context lines preceding the group and the first added/deleted line, and the other between the last added/deleted line and the first line of context following it. It tries to find the positioning that creates the least bad splits. Splits are evaluated based only on the presence and locations of nearby blank lines, and the indentation of lines near the split. Basically, it prefers to introduce splits adjacent to blank lines, between lines that are indented less, and between lines with the same level of indentation. In more detail: 1. It measures the following characteristics of a proposed splitting position in a `struct split_measurement`: * the number of blank lines above the proposed split * whether the line directly after the split is blank * the number of blank lines following that line * the indentation of the nearest non-blank line above the split * the indentation of the line directly below the split * the indentation of the nearest non-blank line after that line 2. It combines the measured attributes using a bunch of empirically-optimized weighting factors to derive a `struct split_score` that measures the "badness" of splitting the text at that position. 3. It combines the `split_score` for the top and the bottom of the slider at each of its possible positions, and selects the position that has the best `split_score`. I determined the initial set of weighting factors by collecting a corpus of Git histories from 29 open-source software projects in various programming languages. I generated many diffs from this corpus, and determined the best positioning "by eye" for about 6600 diff sliders. I used about half of the repositories in the corpus (corresponding to about 2/3 of the sliders) as a training set, and optimized the weights against this corpus using a crude automated search of the parameter space to get the best agreement with the manually-determined values. Then I tested the resulting heuristic against the full corpus. The results are summarized in the following table, in column `indent-1`: | repository | count | Git 2.9.0 | compaction | compaction-fixed | indent-1 | indent-2 | | --------------------- | ----- | -------------- | -------------- | ---------------- | -------------- | -------------- | | afnetworking | 109 | 89 (81.7%) | 37 (33.9%) | 37 (33.9%) | 2 (1.8%) | 2 (1.8%) | | alamofire | 30 | 18 (60.0%) | 14 (46.7%) | 15 (50.0%) | 0 (0.0%) | 0 (0.0%) | | angular | 184 | 127 (69.0%) | 39 (21.2%) | 23 (12.5%) | 5 (2.7%) | 5 (2.7%) | | animate | 313 | 2 (0.6%) | 2 (0.6%) | 2 (0.6%) | 2 (0.6%) | 2 (0.6%) | | ant | 380 | 356 (93.7%) | 152 (40.0%) | 148 (38.9%) | 15 (3.9%) | 15 (3.9%) | * | bugzilla | 306 | 263 (85.9%) | 109 (35.6%) | 99 (32.4%) | 14 (4.6%) | 15 (4.9%) | * | corefx | 126 | 91 (72.2%) | 22 (17.5%) | 21 (16.7%) | 6 (4.8%) | 6 (4.8%) | | couchdb | 78 | 44 (56.4%) | 26 (33.3%) | 28 (35.9%) | 6 (7.7%) | 6 (7.7%) | * | cpython | 937 | 158 (16.9%) | 50 (5.3%) | 49 (5.2%) | 5 (0.5%) | 5 (0.5%) | * | discourse | 160 | 95 (59.4%) | 42 (26.2%) | 36 (22.5%) | 18 (11.2%) | 13 (8.1%) | | docker | 307 | 194 (63.2%) | 198 (64.5%) | 253 (82.4%) | 8 (2.6%) | 8 (2.6%) | * | electron | 163 | 132 (81.0%) | 38 (23.3%) | 39 (23.9%) | 6 (3.7%) | 6 (3.7%) | | git | 536 | 470 (87.7%) | 73 (13.6%) | 78 (14.6%) | 16 (3.0%) | 16 (3.0%) | * | gitflow | 127 | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | | ionic | 133 | 89 (66.9%) | 29 (21.8%) | 38 (28.6%) | 1 (0.8%) | 1 (0.8%) | | ipython | 482 | 362 (75.1%) | 167 (34.6%) | 169 (35.1%) | 11 (2.3%) | 11 (2.3%) | * | junit | 161 | 147 (91.3%) | 67 (41.6%) | 66 (41.0%) | 1 (0.6%) | 1 (0.6%) | * | lighttable | 15 | 5 (33.3%) | 0 (0.0%) | 2 (13.3%) | 0 (0.0%) | 0 (0.0%) | | magit | 88 | 75 (85.2%) | 11 (12.5%) | 9 (10.2%) | 1 (1.1%) | 0 (0.0%) | | neural-style | 28 | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | | nodejs | 781 | 649 (83.1%) | 118 (15.1%) | 111 (14.2%) | 4 (0.5%) | 5 (0.6%) | * | phpmyadmin | 491 | 481 (98.0%) | 75 (15.3%) | 48 (9.8%) | 2 (0.4%) | 2 (0.4%) | * | react-native | 168 | 130 (77.4%) | 79 (47.0%) | 81 (48.2%) | 0 (0.0%) | 0 (0.0%) | | rust | 171 | 128 (74.9%) | 30 (17.5%) | 27 (15.8%) | 16 (9.4%) | 14 (8.2%) | | spark | 186 | 149 (80.1%) | 52 (28.0%) | 52 (28.0%) | 2 (1.1%) | 2 (1.1%) | | tensorflow | 115 | 66 (57.4%) | 48 (41.7%) | 48 (41.7%) | 5 (4.3%) | 5 (4.3%) | | test-more | 19 | 15 (78.9%) | 2 (10.5%) | 2 (10.5%) | 1 (5.3%) | 1 (5.3%) | * | test-unit | 51 | 34 (66.7%) | 14 (27.5%) | 8 (15.7%) | 2 (3.9%) | 2 (3.9%) | * | xmonad | 23 | 22 (95.7%) | 2 (8.7%) | 2 (8.7%) | 1 (4.3%) | 1 (4.3%) | * | --------------------- | ----- | -------------- | -------------- | ---------------- | -------------- | -------------- | | totals | 6668 | 4391 (65.9%) | 1496 (22.4%) | 1491 (22.4%) | 150 (2.2%) | 144 (2.2%) | | totals (training set) | 4552 | 3195 (70.2%) | 1053 (23.1%) | 1061 (23.3%) | 86 (1.9%) | 88 (1.9%) | | totals (test set) | 2116 | 1196 (56.5%) | 443 (20.9%) | 430 (20.3%) | 64 (3.0%) | 56 (2.6%) | In this table, the numbers are the count and percentage of human-rated sliders that the corresponding algorithm got *wrong*. The columns are * "repository" - the name of the repository used. I used the diffs between successive non-merge commits on the HEAD branch of the corresponding repository. * "count" - the number of sliders that were human-rated. I chose most, but not all, sliders to rate from those among which the various algorithms gave different answers. * "Git 2.9.0" - the default algorithm used by `git diff` in Git 2.9.0. * "compaction" - the heuristic used by `git diff --compaction-heuristic` in Git 2.9.0. * "compaction-fixed" - the heuristic used by `git diff --compaction-heuristic` after the fixes from earlier in this patch series. Note that the results are not dramatically different than those for "compaction". Both produce non-ideal diffs only about 1/3 as often as the default `git diff`. * "indent-1" - the new `--indent-heuristic` algorithm, using the first set of weighting factors, determined as described above. * "indent-2" - the new `--indent-heuristic` algorithm, using the final set of weighting factors, determined as described below. * `*` - indicates that repo was part of training set used to determine the first set of weighting factors. The fact that the heuristic performed nearly as well on the test set as on the training set in column "indent-1" is a good indication that the heuristic was not over-trained. Given that fact, I ran a second round of optimization, using the entire corpus as the training set. The resulting set of weights gave the results in column "indent-2". These are the weights included in this patch. The final result gives consistently and significantly better results across the whole corpus than either `git diff` or `git diff --compaction-heuristic`. It makes only about 1/30 as many errors as the former and about 1/10 as many errors as the latter. (And a good fraction of the remaining errors are for diffs that involve weirdly-formatted code, sometimes apparently machine-generated.) The tools that were used to do this optimization and analysis, along with the human-generated data values, are recorded in a separate project [1]. This patch adds a new command-line option `--indent-heuristic`, and a new configuration setting `diff.indentHeuristic`, that activate this heuristic. This interface is only meant for testing purposes, and should be finalized before including this change in any release. [1] https://github.com/mhagger/diff-slider-tools Signed-off-by: Michael Haggerty <mhagger@alum.mit.edu> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Michael Haggerty committed Sep 5, 2016 at 11:44 UTC 433860f3d0beb0c6f205290bd16cda413148f098
7 files changed +546 -11
Documentation/diff-config.txt
+4 -3
@@ -170,10 +170,11 @@ diff.tool::
170
171 include::mergetools-diff.txt[]
172
173 +diff.indentHeuristic::
174 diff.compactionHeuristic::
174 - Set this option to `true` to enable an experimental heuristic that
175 - shifts the hunk boundary in an attempt to make the resulting
176 - patch easier to read.
175 + Set one of these options to `true` to enable one of two
176 + experimental heuristics that shift diff hunk boundaries to
177 + make patches easier to read.
178
179 diff.algorithm::
180 Choose a diff algorithm. The variants are as follows:
Documentation/diff-options.txt
+5 -4
@@ -63,12 +63,13 @@ ifndef::git-format-patch[]
63 Synonym for `-p --raw`.
64 endif::git-format-patch[]
65
66 +--indent-heuristic::
67 +--no-indent-heuristic::
68 --compaction-heuristic::
69 --no-compaction-heuristic::
68 - These are to help debugging and tuning an experimental
69 - heuristic (which is off by default) that shifts the hunk
70 - boundary in an attempt to make the resulting patch easier
71 - to read.
70 + These are to help debugging and tuning experimental heuristics
71 + (which are off by default) that shift diff hunk boundaries to
72 + make patches easier to read.
73
74 --minimal::
75 Spend extra time to make sure the smallest possible
diff.c
+20 -3
@@ -26,6 +26,7 @@
26 #endif
27
28 static int diff_detect_rename_default;
29 +static int diff_indent_heuristic; /* experimental */
30 static int diff_compaction_heuristic; /* experimental */
31 static int diff_rename_limit_default = 400;
32 static int diff_suppress_blank_empty;
@@ -190,8 +191,16 @@ int git_diff_ui_config(const char *var, const char *value, void *cb)
191 diff_detect_rename_default = git_config_rename(var, value);
192 return 0;
193 }
194 + if (!strcmp(var, "diff.indentheuristic")) {
195 + diff_indent_heuristic = git_config_bool(var, value);
196 + if (diff_indent_heuristic)
197 + diff_compaction_heuristic = 0;
198 + return 0;
199 + }
200 if (!strcmp(var, "diff.compactionheuristic")) {
201 diff_compaction_heuristic = git_config_bool(var, value);
202 + if (diff_compaction_heuristic)
203 + diff_indent_heuristic = 0;
204 return 0;
205 }
206 if (!strcmp(var, "diff.autorefreshindex")) {
@@ -3283,7 +3292,9 @@ void diff_setup(struct diff_options *options)
3292 options->use_color = diff_use_color_default;
3293 options->detect_rename = diff_detect_rename_default;
3294 options->xdl_opts |= diff_algorithm;
3286 - if (diff_compaction_heuristic)
3295 + if (diff_indent_heuristic)
3296 + DIFF_XDL_SET(options, INDENT_HEURISTIC);
3297 + else if (diff_compaction_heuristic)
3298 DIFF_XDL_SET(options, COMPACTION_HEURISTIC);
3299
3300 options->orderfile = diff_order_file_cfg;
@@ -3805,9 +3816,15 @@ int diff_opt_parse(struct diff_options *options,
3816 DIFF_XDL_SET(options, IGNORE_WHITESPACE_AT_EOL);
3817 else if (!strcmp(arg, "--ignore-blank-lines"))
3818 DIFF_XDL_SET(options, IGNORE_BLANK_LINES);
3808 - else if (!strcmp(arg, "--compaction-heuristic"))
3819 + else if (!strcmp(arg, "--indent-heuristic")) {
3820 + DIFF_XDL_SET(options, INDENT_HEURISTIC);
3821 + DIFF_XDL_CLR(options, COMPACTION_HEURISTIC);
3822 + } else if (!strcmp(arg, "--no-indent-heuristic"))
3823 + DIFF_XDL_CLR(options, INDENT_HEURISTIC);
3824 + else if (!strcmp(arg, "--compaction-heuristic")) {
3825 DIFF_XDL_SET(options, COMPACTION_HEURISTIC);
3810 - else if (!strcmp(arg, "--no-compaction-heuristic"))
3826 + DIFF_XDL_CLR(options, INDENT_HEURISTIC);
3827 + } else if (!strcmp(arg, "--no-compaction-heuristic"))
3828 DIFF_XDL_CLR(options, COMPACTION_HEURISTIC);
3829 else if (!strcmp(arg, "--patience"))
3830 options->xdl_opts = DIFF_WITH_ALG(options, PATIENCE_DIFF);
git-add--interactive.perl
+4 -1
@@ -45,6 +45,7 @@ my ($diff_new_color) =
45 my $normal_color = $repo->get_color("", "reset");
46
47 my $diff_algorithm = $repo->config('diff.algorithm');
48 +my $diff_indent_heuristic = $repo->config_bool('diff.indentheuristic');
49 my $diff_compaction_heuristic = $repo->config_bool('diff.compactionheuristic');
50 my $diff_filter = $repo->config('interactive.difffilter');
51
@@ -750,7 +751,9 @@ sub parse_diff {
751 if (defined $diff_algorithm) {
752 splice @diff_cmd, 1, 0, "--diff-algorithm=${diff_algorithm}";
753 }
753 - if ($diff_compaction_heuristic) {
754 + if ($diff_indent_heuristic) {
755 + splice @diff_cmd, 1, 0, "--indent-heuristic";
756 + } elsif ($diff_compaction_heuristic) {
757 splice @diff_cmd, 1, 0, "--compaction-heuristic";
758 }
759 if (defined $patch_mode_revision) {
t/t4061-diff-indent.sh new
+187
@@ -0,0 +1,187 @@
1 +#!/bin/sh
2 +
3 +test_description='Test diff indent heuristic.
4 +
5 +'
6 +. ./test-lib.sh
7 +. "$TEST_DIRECTORY"/diff-lib.sh
8 +
9 +# Compare two diff outputs. Ignore "index" lines, because we don't
10 +# care about SHA-1s or file modes.
11 +compare_diff () {
12 + sed -e "/^index /d" <"$1" >.tmp-1
13 + sed -e "/^index /d" <"$2" >.tmp-2
14 + test_cmp .tmp-1 .tmp-2 && rm -f .tmp-1 .tmp-2
15 +}
16 +
17 +test_expect_success 'prepare' '
18 + cat <<-\EOF >spaces.txt &&
19 + 1
20 + 2
21 + a
22 +
23 + b
24 + 3
25 + 4
26 + EOF
27 +
28 + cat <<-\EOF >functions.c &&
29 + 1
30 + 2
31 + /* function */
32 + foo() {
33 + foo
34 + }
35 +
36 + 3
37 + 4
38 + EOF
39 +
40 + git add spaces.txt functions.c &&
41 + test_tick &&
42 + git commit -m initial &&
43 + git branch old &&
44 +
45 + cat <<-\EOF >spaces.txt &&
46 + 1
47 + 2
48 + a
49 +
50 + b
51 + a
52 +
53 + b
54 + 3
55 + 4
56 + EOF
57 +
58 + cat <<-\EOF >functions.c &&
59 + 1
60 + 2
61 + /* function */
62 + bar() {
63 + foo
64 + }
65 +
66 + /* function */
67 + foo() {
68 + foo
69 + }
70 +
71 + 3
72 + 4
73 + EOF
74 +
75 + git add spaces.txt functions.c &&
76 + test_tick &&
77 + git commit -m initial &&
78 + git branch new &&
79 +
80 + tr "_" " " <<-\EOF >spaces-expect &&
81 + diff --git a/spaces.txt b/spaces.txt
82 + --- a/spaces.txt
83 + +++ b/spaces.txt
84 + @@ -3,5 +3,8 @@
85 + a
86 + _
87 + b
88 + +a
89 + +
90 + +b
91 + 3
92 + 4
93 + EOF
94 +
95 + tr "_" " " <<-\EOF >spaces-compacted-expect &&
96 + diff --git a/spaces.txt b/spaces.txt
97 + --- a/spaces.txt
98 + +++ b/spaces.txt
99 + @@ -2,6 +2,9 @@
100 + 2
101 + a
102 + _
103 + +b
104 + +a
105 + +
106 + b
107 + 3
108 + 4
109 + EOF
110 +
111 + tr "_" " " <<-\EOF >functions-expect &&
112 + diff --git a/functions.c b/functions.c
113 + --- a/functions.c
114 + +++ b/functions.c
115 + @@ -1,6 +1,11 @@
116 + 1
117 + 2
118 + /* function */
119 + +bar() {
120 + + foo
121 + +}
122 + +
123 + +/* function */
124 + foo() {
125 + foo
126 + }
127 + EOF
128 +
129 + tr "_" " " <<-\EOF >functions-compacted-expect
130 + diff --git a/functions.c b/functions.c
131 + --- a/functions.c
132 + +++ b/functions.c
133 + @@ -1,5 +1,10 @@
134 + 1
135 + 2
136 + +/* function */
137 + +bar() {
138 + + foo
139 + +}
140 + +
141 + /* function */
142 + foo() {
143 + foo
144 + EOF
145 +'
146 +
147 +test_expect_success 'diff: ugly spaces' '
148 + git diff old new -- spaces.txt >out &&
149 + compare_diff spaces-expect out
150 +'
151 +
152 +test_expect_success 'diff: nice spaces with --indent-heuristic' '
153 + git diff --indent-heuristic old new -- spaces.txt >out-compacted &&
154 + compare_diff spaces-compacted-expect out-compacted
155 +'
156 +
157 +test_expect_success 'diff: nice spaces with diff.indentHeuristic' '
158 + git -c diff.indentHeuristic=true diff old new -- spaces.txt >out-compacted2 &&
159 + compare_diff spaces-compacted-expect out-compacted2
160 +'
161 +
162 +test_expect_success 'diff: --no-indent-heuristic overrides config' '
163 + git -c diff.indentHeuristic=true diff --no-indent-heuristic old new -- spaces.txt >out2 &&
164 + compare_diff spaces-expect out2
165 +'
166 +
167 +test_expect_success 'diff: --indent-heuristic with --patience' '
168 + git diff --indent-heuristic --patience old new -- spaces.txt >out-compacted3 &&
169 + compare_diff spaces-compacted-expect out-compacted3
170 +'
171 +
172 +test_expect_success 'diff: --indent-heuristic with --histogram' '
173 + git diff --indent-heuristic --histogram old new -- spaces.txt >out-compacted4 &&
174 + compare_diff spaces-compacted-expect out-compacted4
175 +'
176 +
177 +test_expect_success 'diff: ugly functions' '
178 + git diff old new -- functions.c >out &&
179 + compare_diff functions-expect out
180 +'
181 +
182 +test_expect_success 'diff: nice functions with --indent-heuristic' '
183 + git diff --indent-heuristic old new -- functions.c >out-compacted &&
184 + compare_diff functions-compacted-expect out-compacted
185 +'
186 +
187 +test_done
xdiff/xdiff.h
+1
@@ -42,6 +42,7 @@ extern "C" {
42 #define XDF_IGNORE_BLANK_LINES (1 << 7)
43
44 #define XDF_COMPACTION_HEURISTIC (1 << 8)
45 +#define XDF_INDENT_HEURISTIC (1 << 9)
46
47 #define XDL_EMIT_FUNCNAMES (1 << 0)
48 #define XDL_EMIT_FUNCCONTEXT (1 << 2)
xdiff/xdiffi.c
+325
@@ -413,6 +413,286 @@ static int recs_match(xrecord_t *rec1, xrecord_t *rec2, long flags)
413 flags));
414 }
415
416 +/*
417 + * If a line is indented more than this, get_indent() just returns this value.
418 + * This avoids having to do absurd amounts of work for data that are not
419 + * human-readable text, and also ensures that the output of get_indent fits within
420 + * an int.
421 + */
422 +#define MAX_INDENT 200
423 +
424 +/*
425 + * Return the amount of indentation of the specified line, treating TAB as 8
426 + * columns. Return -1 if line is empty or contains only whitespace. Clamp the
427 + * output value at MAX_INDENT.
428 + */
429 +static int get_indent(xrecord_t *rec)
430 +{
431 + long i;
432 + int ret = 0;
433 +
434 + for (i = 0; i < rec->size; i++) {
435 + char c = rec->ptr[i];
436 +
437 + if (!XDL_ISSPACE(c))
438 + return ret;
439 + else if (c == ' ')
440 + ret += 1;
441 + else if (c == '\t')
442 + ret += 8 - ret % 8;
443 + /* ignore other whitespace characters */
444 +
445 + if (ret >= MAX_INDENT)
446 + return MAX_INDENT;
447 + }
448 +
449 + /* The line contains only whitespace. */
450 + return -1;
451 +}
452 +
453 +/*
454 + * If more than this number of consecutive blank rows are found, just return this
455 + * value. This avoids requiring O(N^2) work for pathological cases, and also
456 + * ensures that the output of score_split fits in an int.
457 + */
458 +#define MAX_BLANKS 20
459 +
460 +/* Characteristics measured about a hypothetical split position. */
461 +struct split_measurement {
462 + /*
463 + * Is the split at the end of the file (aside from any blank lines)?
464 + */
465 + int end_of_file;
466 +
467 + /*
468 + * How much is the line immediately following the split indented (or -1 if
469 + * the line is blank):
470 + */
471 + int indent;
472 +
473 + /*
474 + * How many consecutive lines above the split are blank?
475 + */
476 + int pre_blank;
477 +
478 + /*
479 + * How much is the nearest non-blank line above the split indented (or -1
480 + * if there is no such line)?
481 + */
482 + int pre_indent;
483 +
484 + /*
485 + * How many lines after the line following the split are blank?
486 + */
487 + int post_blank;
488 +
489 + /*
490 + * How much is the nearest non-blank line after the line following the
491 + * split indented (or -1 if there is no such line)?
492 + */
493 + int post_indent;
494 +};
495 +
496 +struct split_score {
497 + /* The effective indent of this split (smaller is preferred). */
498 + int effective_indent;
499 +
500 + /* Penalty for this split (smaller is preferred). */
501 + int penalty;
502 +};
503 +
504 +/*
505 + * Fill m with information about a hypothetical split of xdf above line split.
506 + */
507 +static void measure_split(const xdfile_t *xdf, long split,
508 + struct split_measurement *m)
509 +{
510 + long i;
511 +
512 + if (split >= xdf->nrec) {
513 + m->end_of_file = 1;
514 + m->indent = -1;
515 + } else {
516 + m->end_of_file = 0;
517 + m->indent = get_indent(xdf->recs[split]);
518 + }
519 +
520 + m->pre_blank = 0;
521 + m->pre_indent = -1;
522 + for (i = split - 1; i >= 0; i--) {
523 + m->pre_indent = get_indent(xdf->recs[i]);
524 + if (m->pre_indent != -1)
525 + break;
526 + m->pre_blank += 1;
527 + if (m->pre_blank == MAX_BLANKS) {
528 + m->pre_indent = 0;
529 + break;
530 + }
531 + }
532 +
533 + m->post_blank = 0;
534 + m->post_indent = -1;
535 + for (i = split + 1; i < xdf->nrec; i++) {
536 + m->post_indent = get_indent(xdf->recs[i]);
537 + if (m->post_indent != -1)
538 + break;
539 + m->post_blank += 1;
540 + if (m->post_blank == MAX_BLANKS) {
541 + m->post_indent = 0;
542 + break;
543 + }
544 + }
545 +}
546 +
547 +/*
548 + * The empirically-determined weight factors used by score_split() below.
549 + * Larger values means that the position is a less favorable place to split.
550 + *
551 + * Note that scores are only ever compared against each other, so multiplying
552 + * all of these weight/penalty values by the same factor wouldn't change the
553 + * heuristic's behavior. Still, we need to set that arbitrary scale *somehow*.
554 + * In practice, these numbers are chosen to be large enough that they can be
555 + * adjusted relative to each other with sufficient precision despite using
556 + * integer math.
557 + */
558 +
559 +/* Penalty if there are no non-blank lines before the split */
560 +#define START_OF_FILE_PENALTY 1
561 +
562 +/* Penalty if there are no non-blank lines after the split */
563 +#define END_OF_FILE_PENALTY 21
564 +
565 +/* Multiplier for the number of blank lines around the split */
566 +#define TOTAL_BLANK_WEIGHT (-30)
567 +
568 +/* Multiplier for the number of blank lines after the split */
569 +#define POST_BLANK_WEIGHT 6
570 +
571 +/*
572 + * Penalties applied if the line is indented more than its predecessor
573 + */
574 +#define RELATIVE_INDENT_PENALTY (-4)
575 +#define RELATIVE_INDENT_WITH_BLANK_PENALTY 10
576 +
577 +/*
578 + * Penalties applied if the line is indented less than both its predecessor and
579 + * its successor
580 + */
581 +#define RELATIVE_OUTDENT_PENALTY 24
582 +#define RELATIVE_OUTDENT_WITH_BLANK_PENALTY 17
583 +
584 +/*
585 + * Penalties applied if the line is indented less than its predecessor but not
586 + * less than its successor
587 + */
588 +#define RELATIVE_DEDENT_PENALTY 23
589 +#define RELATIVE_DEDENT_WITH_BLANK_PENALTY 17
590 +
591 +/*
592 + * We only consider whether the sum of the effective indents for splits are
593 + * less than (-1), equal to (0), or greater than (+1) each other. The resulting
594 + * value is multiplied by the following weight and combined with the penalty to
595 + * determine the better of two scores.
596 + */
597 +#define INDENT_WEIGHT 60
598 +
599 +/*
600 + * Compute a badness score for the hypothetical split whose measurements are
601 + * stored in m. The weight factors were determined empirically using the tools and
602 + * corpus described in
603 + *
604 + * https://github.com/mhagger/diff-slider-tools
605 + *
606 + * Also see that project if you want to improve the weights based on, for example,
607 + * a larger or more diverse corpus.
608 + */
609 +static void score_add_split(const struct split_measurement *m, struct split_score *s)
610 +{
611 + /*
612 + * A place to accumulate penalty factors (positive makes this index more
613 + * favored):
614 + */
615 + int post_blank, total_blank, indent, any_blanks;
616 +
617 + if (m->pre_indent == -1 && m->pre_blank == 0)
618 + s->penalty += START_OF_FILE_PENALTY;
619 +
620 + if (m->end_of_file)
621 + s->penalty += END_OF_FILE_PENALTY;
622 +
623 + /*
624 + * Set post_blank to the number of blank lines following the split,
625 + * including the line immediately after the split:
626 + */
627 + post_blank = (m->indent == -1) ? 1 + m->post_blank : 0;
628 + total_blank = m->pre_blank + post_blank;
629 +
630 + /* Penalties based on nearby blank lines: */
631 + s->penalty += TOTAL_BLANK_WEIGHT * total_blank;
632 + s->penalty += POST_BLANK_WEIGHT * post_blank;
633 +
634 + if (m->indent != -1)
635 + indent = m->indent;
636 + else
637 + indent = m->post_indent;
638 +
639 + any_blanks = (total_blank != 0);
640 +
641 + /* Note that the effective indent is -1 at the end of the file: */
642 + s->effective_indent += indent;
643 +
644 + if (indent == -1) {
645 + /* No additional adjustments needed. */
646 + } else if (m->pre_indent == -1) {
647 + /* No additional adjustments needed. */
648 + } else if (indent > m->pre_indent) {
649 + /*
650 + * The line is indented more than its predecessor.
651 + */
652 + s->penalty += any_blanks ?
653 + RELATIVE_INDENT_WITH_BLANK_PENALTY :
654 + RELATIVE_INDENT_PENALTY;
655 + } else if (indent == m->pre_indent) {
656 + /*
657 + * The line has the same indentation level as its predecessor.
658 + * No additional adjustments needed.
659 + */
660 + } else {
661 + /*
662 + * The line is indented less than its predecessor. It could be
663 + * the block terminator of the previous block, but it could
664 + * also be the start of a new block (e.g., an "else" block, or
665 + * maybe the previous block didn't have a block terminator).
666 + * Try to distinguish those cases based on what comes next:
667 + */
668 + if (m->post_indent != -1 && m->post_indent > indent) {
669 + /*
670 + * The following line is indented more. So it is likely
671 + * that this line is the start of a block.
672 + */
673 + s->penalty += any_blanks ?
674 + RELATIVE_OUTDENT_WITH_BLANK_PENALTY :
675 + RELATIVE_OUTDENT_PENALTY;
676 + } else {
677 + /*
678 + * That was probably the end of a block.
679 + */
680 + s->penalty += any_blanks ?
681 + RELATIVE_DEDENT_WITH_BLANK_PENALTY :
682 + RELATIVE_DEDENT_PENALTY;
683 + }
684 + }
685 +}
686 +
687 +static int score_cmp(struct split_score *s1, struct split_score *s2)
688 +{
689 + /* -1 if s1.effective_indent < s2->effective_indent, etc. */
690 + int cmp_indents = ((s1->effective_indent > s2->effective_indent) -
691 + (s1->effective_indent < s2->effective_indent));
692 +
693 + return INDENT_WEIGHT * cmp_indents + (s1->penalty - s2->penalty);
694 +}
695 +
696 /*
697 * Represent a group of changed lines in an xdfile_t (i.e., a contiguous group
698 * of lines that was inserted or deleted from the corresponding version of the
@@ -604,6 +884,14 @@ int xdl_change_compact(xdfile_t *xdf, xdfile_t *xdfo, long flags) {
884 }
885 } while (groupsize != g.end - g.start);
886
887 + /*
888 + * If the group can be shifted, then we can possibly use this
889 + * freedom to produce a more intuitive diff.
890 + *
891 + * The group is currently shifted as far down as possible, so the
892 + * heuristics below only have to handle upwards shifts.
893 + */
894 +
895 if (g.end == earliest_end) {
896 /* no shifting was possible */
897 } else if (end_matching_other != -1) {
@@ -633,6 +921,43 @@ int xdl_change_compact(xdfile_t *xdf, xdfile_t *xdfo, long flags) {
921 if (group_previous(xdfo, &go))
922 xdl_bug("group sync broken sliding to blank line");
923 }
924 + } else if (flags & XDF_INDENT_HEURISTIC) {
925 + /*
926 + * Indent heuristic: a group of pure add/delete lines
927 + * implies two splits, one between the end of the "before"
928 + * context and the start of the group, and another between
929 + * the end of the group and the beginning of the "after"
930 + * context. Some splits are aesthetically better and some
931 + * are worse. We compute a badness "score" for each split,
932 + * and add the scores for the two splits to define a
933 + * "score" for each position that the group can be shifted
934 + * to. Then we pick the shift with the lowest score.
935 + */
936 + long shift, best_shift = -1;
937 + struct split_score best_score;
938 +
939 + for (shift = earliest_end; shift <= g.end; shift++) {
940 + struct split_measurement m;
941 + struct split_score score = {0, 0};
942 +
943 + measure_split(xdf, shift, &m);
944 + score_add_split(&m, &score);
945 + measure_split(xdf, shift - groupsize, &m);
946 + score_add_split(&m, &score);
947 + if (best_shift == -1 ||
948 + score_cmp(&score, &best_score) <= 0) {
949 + best_score.effective_indent = score.effective_indent;
950 + best_score.penalty = score.penalty;
951 + best_shift = shift;
952 + }
953 + }
954 +
955 + while (g.end > best_shift) {
956 + if (group_slide_up(xdf, &g, flags))
957 + xdl_bug("best shift unreached");
958 + if (group_previous(xdfo, &go))
959 + xdl_bug("group sync broken sliding to blank line");
960 + }
961 }
962
963 next: