Skip to content

Commit f7b4c16

Browse files
committed
Make whitespace (' ', \t, \r, \n) always visible for "changed" lines
context lines, added-only lines, and removed-only lines are shown as usual in the diffs. fixes diffplug#465
1 parent 04d5c7d commit f7b4c16

File tree

3 files changed

+433
-193
lines changed

3 files changed

+433
-193
lines changed

lib-extra/src/main/java/com/diffplug/spotless/extra/integration/DiffMessageFormatter.java

+15-58
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,17 @@
1818
import java.io.ByteArrayOutputStream;
1919
import java.io.File;
2020
import java.io.IOException;
21-
import java.nio.charset.StandardCharsets;
21+
import java.nio.charset.Charset;
2222
import java.nio.file.Files;
2323
import java.util.List;
2424
import java.util.ListIterator;
2525
import java.util.Objects;
2626

27-
import org.eclipse.jgit.diff.DiffFormatter;
2827
import org.eclipse.jgit.diff.EditList;
29-
import org.eclipse.jgit.diff.MyersDiff;
28+
import org.eclipse.jgit.diff.HistogramDiff;
3029
import org.eclipse.jgit.diff.RawText;
3130
import org.eclipse.jgit.diff.RawTextComparator;
3231

33-
import com.diffplug.common.base.CharMatcher;
3432
import com.diffplug.common.base.Errors;
3533
import com.diffplug.common.base.Preconditions;
3634
import com.diffplug.common.base.Splitter;
@@ -168,7 +166,9 @@ private void addIntendedLine(String indent, String line) {
168166
* sequence (\n, \r, \r\n).
169167
*/
170168
private static String diff(Builder builder, File file) throws IOException {
171-
String raw = new String(Files.readAllBytes(file.toPath()), builder.formatter.getEncoding());
169+
byte[] rawBytes = Files.readAllBytes(file.toPath());
170+
Charset encoding = builder.formatter.getEncoding();
171+
String raw = new String(rawBytes, encoding);
172172
String rawUnix = LineEnding.toUnix(raw);
173173
String formattedUnix;
174174
if (builder.isPaddedCell) {
@@ -177,61 +177,18 @@ private static String diff(Builder builder, File file) throws IOException {
177177
formattedUnix = builder.formatter.compute(rawUnix, file);
178178
}
179179

180-
if (rawUnix.equals(formattedUnix)) {
181-
// the formatting is fine, so it's a line-ending issue
182-
String formatted = builder.formatter.computeLineEndings(formattedUnix, file);
183-
return diffWhitespaceLineEndings(raw, formatted, false, true);
184-
} else {
185-
return diffWhitespaceLineEndings(rawUnix, formattedUnix, true, false);
186-
}
180+
String formatted = builder.formatter.computeLineEndings(formattedUnix, file);
181+
// TODO: use per-file encoding (e.g. from .editorconfig)
182+
byte[] formattedBytes = formatted.getBytes(encoding);
183+
return visualizeDiff(rawBytes, formattedBytes, encoding);
187184
}
188185

189-
/**
190-
* Returns a git-style diff between the two unix strings.
191-
*
192-
* Output has no trailing newlines.
193-
*
194-
* Boolean args determine whether whitespace or line endings will be visible.
195-
*/
196-
private static String diffWhitespaceLineEndings(String dirty, String clean, boolean whitespace, boolean lineEndings) throws IOException {
197-
dirty = visibleWhitespaceLineEndings(dirty, whitespace, lineEndings);
198-
clean = visibleWhitespaceLineEndings(clean, whitespace, lineEndings);
199-
200-
RawText a = new RawText(dirty.getBytes(StandardCharsets.UTF_8));
201-
RawText b = new RawText(clean.getBytes(StandardCharsets.UTF_8));
202-
EditList edits = new EditList();
203-
edits.addAll(MyersDiff.INSTANCE.diff(RawTextComparator.DEFAULT, a, b));
204-
186+
private static String visualizeDiff(byte[] rawBytes, byte[] formattedBytes, Charset encoding) throws IOException {
187+
RawText a = new RawText(rawBytes);
188+
RawText b = new RawText(formattedBytes);
189+
EditList edits = new HistogramDiff().diff(RawTextComparator.DEFAULT, a, b);
205190
ByteArrayOutputStream out = new ByteArrayOutputStream();
206-
try (DiffFormatter formatter = new DiffFormatter(out)) {
207-
formatter.format(edits, a, b);
208-
}
209-
String formatted = out.toString(StandardCharsets.UTF_8.name());
210-
211-
// we don't need the diff to show this, since we display newlines ourselves
212-
formatted = formatted.replace("\\ No newline at end of file\n", "");
213-
return NEWLINE_MATCHER.trimTrailingFrom(formatted);
191+
new WriteSpaceAwareDiffFormatter(out, encoding).format(edits, a, b);
192+
return new String(out.toByteArray(), encoding);
214193
}
215-
216-
private static final CharMatcher NEWLINE_MATCHER = CharMatcher.is('\n');
217-
218-
/**
219-
* Makes the whitespace and/or the lineEndings visible.
220-
*
221-
* MyersDiff wants inputs with only unix line endings. So this ensures that that is the case.
222-
*/
223-
private static String visibleWhitespaceLineEndings(String input, boolean whitespace, boolean lineEndings) {
224-
if (whitespace) {
225-
input = input.replace(' ', MIDDLE_DOT).replace("\t", "\\t");
226-
}
227-
if (lineEndings) {
228-
input = input.replace("\n", "\\n\n").replace("\r", "\\r");
229-
} else {
230-
// we want only \n, so if we didn't replace them above, we'll replace them here.
231-
input = input.replace("\r", "");
232-
}
233-
return input;
234-
}
235-
236-
private static final char MIDDLE_DOT = '\u00b7';
237194
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* Copyright 2016 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.spotless.extra.integration;
17+
18+
import java.io.ByteArrayOutputStream;
19+
import java.io.IOException;
20+
import java.nio.ByteBuffer;
21+
import java.nio.CharBuffer;
22+
import java.nio.charset.CharacterCodingException;
23+
import java.nio.charset.Charset;
24+
25+
import org.bouncycastle.util.Arrays;
26+
import org.eclipse.jgit.diff.Edit;
27+
import org.eclipse.jgit.diff.EditList;
28+
import org.eclipse.jgit.diff.RawText;
29+
import org.eclipse.jgit.util.IntList;
30+
import org.eclipse.jgit.util.RawParseUtils;
31+
32+
/**
33+
* Formats the diff in Git-like style, however it makes whitespace visible for
34+
* edit-like diffs (when one fragment is replaced with another).
35+
*/
36+
class WriteSpaceAwareDiffFormatter {
37+
private static final int CONTEXT_LINES = 3;
38+
private static final String MIDDLE_DOT = "\u00b7";
39+
private static final String CR = "\u240d";
40+
private static final String LF = "\u240a";
41+
private static final String TAB = "\u21e5";
42+
private static final byte[] ONE_SPACE = new byte[]{' '};
43+
private static final byte[] CR_SIMPLE = new byte[]{'\\', 'r'};
44+
private static final byte[] LF_SIMPLE = new byte[]{'\\', 'n'};
45+
private static final byte[] TAB_SIMPLE = new byte[]{'\\', 't'};
46+
47+
private final ByteArrayOutputStream out;
48+
private final byte[] middleDot;
49+
private final byte[] cr;
50+
private final byte[] lf;
51+
private final byte[] tab;
52+
53+
/**
54+
* Creates the formatter.
55+
* @param out output stream for the resulting diff. The diff would have \n line endings
56+
* @param charset the charset for the text to use
57+
*/
58+
public WriteSpaceAwareDiffFormatter(ByteArrayOutputStream out, Charset charset) {
59+
this.out = out;
60+
this.middleDot = replacementFor(MIDDLE_DOT, charset, ONE_SPACE);
61+
this.cr = replacementFor(CR, charset, CR_SIMPLE);
62+
this.lf = replacementFor(LF, charset, LF_SIMPLE);
63+
this.tab = replacementFor(TAB, charset, TAB_SIMPLE); // \u2409 is missing in lots of the fonts
64+
}
65+
66+
private static byte[] replacementFor(String value, Charset charset, byte[] defaultValue) {
67+
try {
68+
ByteBuffer buffer = charset.newEncoder()
69+
.replaceWith(ONE_SPACE)
70+
.encode(CharBuffer.wrap(value));
71+
return Arrays.copyOf(buffer.array(), buffer.remaining());
72+
} catch (CharacterCodingException e) {
73+
return defaultValue;
74+
}
75+
}
76+
77+
/**
78+
* Formats the diff.
79+
* @param edits the list of edits to format
80+
* @param a input text a, with \n line endings
81+
* @param b input text b, with \n line endings
82+
* @throws IOException if formatting fails
83+
*/
84+
public void format(EditList edits, RawText a, RawText b) throws IOException {
85+
IntList linesA = RawParseUtils.lineMap(a.getRawContent(), 0, a.getRawContent().length);
86+
IntList linesB = RawParseUtils.lineMap(b.getRawContent(), 0, b.getRawContent().length);
87+
boolean firstLine = true;
88+
for (int i = 0; i < edits.size(); i++) {
89+
Edit edit = edits.get(i);
90+
int lineA = Math.max(0, edit.getBeginA() - CONTEXT_LINES);
91+
int lineB = Math.max(0, edit.getBeginB() - CONTEXT_LINES);
92+
93+
final int endIdx = findCombinedEnd(edits, i);
94+
final Edit endEdit = edits.get(endIdx);
95+
96+
int endA = Math.min(a.size(), endEdit.getEndA() + CONTEXT_LINES);
97+
int endB = Math.min(b.size(), endEdit.getEndB() + CONTEXT_LINES);
98+
99+
if (firstLine) {
100+
firstLine = false;
101+
} else {
102+
out.write('\n');
103+
}
104+
header(lineA, endA, lineB, endB);
105+
106+
boolean showWhitespace = edit.getType() == Edit.Type.REPLACE;
107+
108+
while (lineA < endA || lineB < endB) {
109+
if (lineA < edit.getBeginA()) {
110+
// Common part before the diff
111+
line(' ', a, lineA, linesA, false);
112+
lineA++;
113+
lineB++;
114+
} else if (lineA < edit.getEndA()) {
115+
line('-', a, lineA, linesA, showWhitespace);
116+
lineA++;
117+
} else if (lineB < edit.getEndB()) {
118+
line('+', b, lineB, linesB, showWhitespace);
119+
lineB++;
120+
} else {
121+
// Common part after the diff
122+
line(' ', a, lineA, linesA, false);
123+
lineA++;
124+
lineB++;
125+
}
126+
127+
if (lineA == edit.getEndA() && lineB == edit.getEndB() && i < endIdx) {
128+
i++;
129+
edit = edits.get(i);
130+
showWhitespace = edit.getType() == Edit.Type.REPLACE;
131+
}
132+
}
133+
}
134+
}
135+
136+
/**
137+
* There might be multiple adjacent diffs, so we need to figure out the latest one in the group.
138+
* @param edits list of edits
139+
* @param i starting edit
140+
* @return the index of the latest edit in the group
141+
*/
142+
private int findCombinedEnd(EditList edits, int i) {
143+
for (; i < edits.size() - 1; i++) {
144+
Edit current = edits.get(i);
145+
Edit next = edits.get(i + 1);
146+
if (current.getEndA() - next.getBeginA() > 2 * CONTEXT_LINES &&
147+
current.getEndB() - next.getBeginB() > 2 * CONTEXT_LINES) {
148+
break;
149+
}
150+
}
151+
return i;
152+
}
153+
154+
private void header(int lineA, int endA, int lineB, int endB) {
155+
out.write('@');
156+
out.write('@');
157+
range('-', lineA + 1, endA - lineA);
158+
range('+', lineB + 1, endB - lineB);
159+
out.write(' ');
160+
out.write('@');
161+
out.write('@');
162+
}
163+
164+
private void range(char prefix, int begin, int length) {
165+
out.write(' ');
166+
out.write(prefix);
167+
if (length == 0) {
168+
writeInt(begin - 1);
169+
out.write(',');
170+
out.write('0');
171+
} else {
172+
writeInt(begin);
173+
if (length > 1) {
174+
out.write(',');
175+
writeInt(length);
176+
}
177+
}
178+
}
179+
180+
private void writeInt(int num) {
181+
String str = Integer.toString(num);
182+
for (int i = 0, len = str.length(); i < len; i++) {
183+
out.write(str.charAt(i));
184+
}
185+
}
186+
187+
private void line(char prefix, RawText a, int lineA, IntList lines, boolean showWhitespace) throws IOException {
188+
out.write('\n');
189+
out.write(prefix);
190+
if (!showWhitespace) {
191+
a.writeLine(out, lineA);
192+
return;
193+
}
194+
byte[] bytes = a.getRawContent();
195+
for (int i = lines.get(lineA + 1), end = lines.get(lineA + 2); i < end; i++) {
196+
byte b = bytes[i];
197+
if (b == ' ') {
198+
out.write(middleDot);
199+
} else if (b == '\t') {
200+
out.write(tab);
201+
} else if (b == '\r') {
202+
out.write(cr);
203+
} else if (b == '\n') {
204+
out.write(lf);
205+
} else {
206+
out.write(b);
207+
}
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)