Skip to content

Commit 0d2bc49

Browse files
authored
Merge pull request #176 from openjavaformat/fix/join-string-literals
Join adjacent string literals that share a line
2 parents af36054 + da784aa commit 0d2bc49

9 files changed

Lines changed: 304 additions & 18 deletions

File tree

‎open-java-format/src/main/java/com/palantir/javaformat/java/StringWrapper.java‎

Lines changed: 170 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
import java.util.stream.Collectors;
5454
import javax.annotation.Nullable;
5555

56-
/** Wraps string literals that exceed the column limit. */
56+
/** Wraps string literals that exceed the column limit, and joins adjacent ones that share a line. */
5757
public final class StringWrapper {
5858

5959
public static final String TEXT_BLOCK_DELIMITER = "\"\"\"";
@@ -66,7 +66,10 @@ public final class StringWrapper {
6666
*/
6767
private static final int MAX_ROUNDS = 5;
6868

69-
/** Reflows string literals in the given Java source code that extend past the given column limit. */
69+
/**
70+
* Reflows string literals in the given Java source code that extend past the given column limit, and joins
71+
* adjacent string literals that share a line.
72+
*/
7073
static String wrap(final int columnLimit, String input, Formatter formatter) throws FormatterException {
7174
String result = wrapOnce(columnLimit, input, formatter);
7275
for (int round = 1; round < MAX_ROUNDS && !result.equals(input); round++) {
@@ -86,6 +89,10 @@ private static String wrapOnce(final int columnLimit, String input, Formatter fo
8689
// style, eight in OJF.
8790
var continuationIndent = 4 * formatter.options().indentationMultiplier();
8891
TreeRangeMap<Integer, String> replacements = getReflowReplacements(columnLimit, continuationIndent, input);
92+
if (replacements.asMapOfRanges().isEmpty()) {
93+
// needWrapping is an over-approximation, so there may be nothing to do after all.
94+
return input;
95+
}
8996
String firstPass =
9097
formatter.formatSource(input, replacements.asMapOfRanges().keySet());
9198

@@ -179,9 +186,105 @@ protected TreeRangeMap<Integer, String> getReflowReplacements() {
179186
TreeRangeMap<Integer, String> replacements = TreeRangeMap.create();
180187
indentTextBlocks(replacements, textBlocks);
181188
wrapLongStrings(replacements, longStringLiterals, stringLiterals);
189+
// Last: it leaves alone the literals that the passes above rewrite.
190+
joinAdjacentLiterals(replacements);
182191
return replacements;
183192
}
184193

194+
/**
195+
* Joins adjacent string literals that share a line, so that {@code "one " + "two"} becomes {@code "one two"}:
196+
* there the {@code +} breaks nothing, whether the author wrote it or the formatter brought the literals onto
197+
* one line. Literals on different lines are wrapped on purpose and stay as they are.
198+
*/
199+
private void joinAdjacentLiterals(TreeRangeMap<Integer, String> replacements) {
200+
new ConcatenationScanner(replacements).scan(new TreePath(unit), null);
201+
}
202+
203+
private class ConcatenationScanner extends TreePathScanner<Void, Void> {
204+
205+
private final TreeRangeMap<Integer, String> replacements;
206+
207+
ConcatenationScanner(TreeRangeMap<Integer, String> replacements) {
208+
this.replacements = replacements;
209+
}
210+
211+
@Override
212+
public Void visitBinary(BinaryTree node, Void unused) {
213+
// Only the root of a chain of +, so that `a + b + c` is flattened once.
214+
if (node.getKind() == Kind.PLUS
215+
&& getCurrentPath().getParentPath().getLeaf().getKind() != Kind.PLUS) {
216+
joinRuns(flattenExpressionTree(node));
217+
}
218+
return super.visitBinary(node, null);
219+
}
220+
221+
/** Replaces each longest run of adjacent literals that can be joined with the one literal they make. */
222+
private void joinRuns(List<Tree> flat) {
223+
var start = 0;
224+
while (start < flat.size()) {
225+
if (!isJoinableLiteral(flat.get(start))) {
226+
start++;
227+
continue;
228+
}
229+
var end = start + 1;
230+
while (end < flat.size() && canJoinWithPrevious(flat.get(end - 1), flat.get(end))) {
231+
end++;
232+
}
233+
if (end - start > 1) {
234+
putJoinReplacement(flat.subList(start, end));
235+
}
236+
start = end;
237+
}
238+
}
239+
240+
private boolean canJoinWithPrevious(Tree previous, Tree next) {
241+
return isJoinableLiteral(next)
242+
&& lineMap.getLineNumber(getEndPosition(unit, previous))
243+
== lineMap.getLineNumber(getStartPosition(next))
244+
&& noComments(input, unit, previous, next)
245+
&& !joiningWouldChangeEscapes(literalText(previous), literalText(next))
246+
&& !endsInLineComment(getStartPosition(next));
247+
}
248+
249+
private boolean isJoinableLiteral(Tree tree) {
250+
return tree.getKind() == Kind.STRING_LITERAL
251+
// indentTextBlocks re-indents a text block, which cannot be spliced into a literal.
252+
&& !input.startsWith(TEXT_BLOCK_DELIMITER, getStartPosition(tree));
253+
}
254+
255+
/**
256+
* Whether the line of a literal that starts at a position ends in a line comment. The literals of such a
257+
* line stay as they are written, because the comment may speak of them that way: a NON-NLS marker counts
258+
* the literals of its line, see {@link NonNlsMarkers}, and a table of literals has a comment for each row.
259+
*/
260+
private boolean endsInLineComment(int position) {
261+
var lineEnd = position;
262+
while (lineEnd < input.length() && Newlines.hasNewlineAt(input, lineEnd) == -1) {
263+
lineEnd++;
264+
}
265+
return lineCommentStart(input.substring(position, lineEnd)) >= 0;
266+
}
267+
268+
private void putJoinReplacement(List<Tree> run) {
269+
var range =
270+
Range.closedOpen(getStartPosition(run.get(0)), getEndPosition(unit, run.get(run.size() - 1)));
271+
if (!replacements.subRangeMap(range).asMapOfRanges().isEmpty()) {
272+
// A text block or a reflowed chain overlaps it; that pass wins.
273+
return;
274+
}
275+
var joined = new StringBuilder("\"");
276+
for (var tree : run) {
277+
joined.append(literalText(tree));
278+
}
279+
replacements.put(range, joined.append('"').toString());
280+
}
281+
282+
/** The source text of a literal between its quotes, with its escapes as they are written. */
283+
private String literalText(Tree tree) {
284+
return input.substring(getStartPosition(tree) + 1, getEndPosition(unit, tree) - 1);
285+
}
286+
}
287+
185288
private class LongStringsAndTextBlockScanner extends TreePathScanner<Void, Void> {
186289

187290
private final List<TreePath> longStringLiterals;
@@ -831,6 +934,62 @@ private static List<Tree> flattenExpressionTree(Tree parent) {
831934
return flat;
832935
}
833936

937+
/**
938+
* Whether appending the source text {@code right} to {@code left} would change what it stands for. That happens
939+
* when {@code left} ends in an octal escape short enough to take in another digit: {@code "\1" + "2"} holds
940+
* {@code \1} and then {@code 2}, but {@code "\12"} is a single character. An octal escape is at most three digits,
941+
* so one of three is safe, as is a run of digits that is not preceded by an odd number of backslashes, which is
942+
* text rather than an escape. It also happens when either holds a Unicode escape: javac reads those before the
943+
* escapes of a literal, so one can stand for the backslash or the digit of such an escape.
944+
*/
945+
private static boolean joiningWouldChangeEscapes(String left, String right) {
946+
if (left.contains("\\u") || right.contains("\\u")) {
947+
return true;
948+
}
949+
if (right.isEmpty() || !isOctalDigit(right.charAt(0))) {
950+
return false;
951+
}
952+
var index = left.length();
953+
while (index > 0 && isOctalDigit(left.charAt(index - 1))) {
954+
index--;
955+
}
956+
var digits = left.length() - index;
957+
if (digits == 0 || digits > 2) {
958+
return false;
959+
}
960+
var backslashes = 0;
961+
while (index - backslashes > 0 && left.charAt(index - backslashes - 1) == '\\') {
962+
backslashes++;
963+
}
964+
return backslashes % 2 == 1;
965+
}
966+
967+
private static boolean isOctalDigit(char character) {
968+
return character >= '0' && character <= '7';
969+
}
970+
971+
/**
972+
* Whether a line may hold two adjacent string literals to join: a {@code +} with a quote right before and after
973+
* it, blanks aside. A cheap over-approximation that keeps the fast paths fast; the {@link Reflower} decides what
974+
* is joined.
975+
*/
976+
private static boolean mayHaveJoinableLiterals(String line) {
977+
for (var plus = line.indexOf('+'); plus != -1; plus = line.indexOf('+', plus + 1)) {
978+
var before = plus - 1;
979+
while (before >= 0 && (line.charAt(before) == ' ' || line.charAt(before) == '\t')) {
980+
before--;
981+
}
982+
var after = plus + 1;
983+
while (after < line.length() && (line.charAt(after) == ' ' || line.charAt(after) == '\t')) {
984+
after++;
985+
}
986+
if (before >= 0 && line.charAt(before) == '"' && after < line.length() && line.charAt(after) == '"') {
987+
return true;
988+
}
989+
}
990+
return false;
991+
}
992+
834993
private static boolean noComments(String input, JCTree.JCCompilationUnit unit, Tree one, Tree two) {
835994
return STRING_CONCAT_DELIMITER.matchesAllOf(
836995
input.subSequence(getEndPosition(unit, one), getStartPosition(two)));
@@ -848,15 +1007,18 @@ private static int getStartPosition(Tree tree) {
8481007
}
8491008

8501009
/**
851-
* Returns true if any lines in the given Java source exceed the column limit or contain text blocks.
852-
* Keep this method and {@code linesNeedWrapping} in line.
1010+
* Returns true if any lines in the given Java source exceed the column limit, contain text blocks or may hold
1011+
* adjacent string literals to join. Keep this method and {@code linesNeedWrapping} in line.
8531012
* */
8541013
private static boolean needWrapping(int columnLimit, String input) {
8551014
// TODO(cushon): consider adding Newlines.lineIterable?
8561015
Iterator<String> it = Newlines.lineIterator(input);
8571016
while (it.hasNext()) {
8581017
String line = it.next();
859-
if (line.length() > columnLimit || line.contains(TEXT_BLOCK_DELIMITER)) {
1018+
if (line.length() > columnLimit
1019+
|| line.contains(TEXT_BLOCK_DELIMITER)
1020+
// Literals joined into a line that fits leave no over-long line to set off the wrapping.
1021+
|| mayHaveJoinableLiterals(line)) {
8601022
return true;
8611023
}
8621024
}
@@ -865,8 +1027,8 @@ private static boolean needWrapping(int columnLimit, String input) {
8651027

8661028
/**
8671029
* Returns true if the lines containing the {@code initialRangesToChange} in the given Java source exceed the
868-
* column limit or contain text blocks. Used by the Intellij plugin to check if we need to run StringWrapper.wrap.
869-
* Keep this method and {@code needWrapping} in line.
1030+
* column limit, contain text blocks or may hold adjacent string literals to join. Used by the Intellij plugin to
1031+
* check if we need to run StringWrapper.wrap. Keep this method and {@code needWrapping} in line.
8701032
* */
8711033
public static boolean linesNeedWrapping(int columnLimit, String input, RangeSet<Integer> initialRangesToChange) {
8721034
// TODO(cushon): consider adding Newlines.lineIterable?
@@ -876,7 +1038,7 @@ public static boolean linesNeedWrapping(int columnLimit, String input, RangeSet<
8761038
boolean insideTextBlock = false;
8771039
while (it.hasNext()) {
8781040
String line = it.next();
879-
if (line.length() > columnLimit) {
1041+
if (line.length() > columnLimit || mayHaveJoinableLiterals(line)) {
8801042
linesToChange.add(Range.closedOpen(i, i + 1));
8811043
}
8821044
if (!insideTextBlock && line.contains(TEXT_BLOCK_DELIMITER)) {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Adjacent string literals that share a line become one literal: as an argument, around other operands of the +, in
2+
# parentheses, after a text block, which stays as it is, and in an annotation and a case label. A chain that the
3+
# formatter brings onto one line is joined too, and the code is laid out again around the joined literal.
4+
# Escapes are copied as they are written: an octal escape of three digits cannot take in the digit after it, a digit
5+
# after an escaped backslash is text, and 8 is not an octal digit.
6+
----------
7+
class T {
8+
void brokenByTheFormatter() {
9+
throw new SafeIllegalArgumentException("Invalid recipient field - contains multiple "
10+
+ "foreign key properties.", UnsafeArg.of("field", maybeField));
11+
}
12+
13+
void runsAroundOtherOperands(String value) {
14+
foo("one "+"two "+ value +"three " + "four");
15+
int length = ( "left"+"right" ).length() + count;
16+
log("a" + 'b' + "c" + 1 + 2 + "d"
17+
+ "e");
18+
}
19+
20+
String twoLines() {
21+
return "a concatenation that the formatter "
22+
+ "pulls onto one line";
23+
}
24+
25+
void escapes() {
26+
String escapes = "tab\there " + "quote\"there " + "backslash\\" + "1";
27+
String maximal = "c" + "\377"+"7" + "d";
28+
String digits = "12" + "3"+"\1" + "8";
29+
}
30+
31+
void nextToATextBlock() {
32+
String block = """
33+
text
34+
"""+"tail"+ " more";
35+
}
36+
37+
@SuppressWarnings("unchecked"
38+
+ "")
39+
void constants(String key) {
40+
switch (key) {
41+
case "first " + "case" -> {}
42+
default -> {}
43+
}
44+
}
45+
}
46+
----------
47+
class T {
48+
void brokenByTheFormatter() {
49+
throw new SafeIllegalArgumentException(
50+
"Invalid recipient field - contains multiple foreign key properties.",
51+
UnsafeArg.of("field", maybeField));
52+
}
53+
54+
void runsAroundOtherOperands(String value) {
55+
foo("one two " + value + "three four");
56+
int length = ("leftright").length() + count;
57+
log("a" + 'b' + "c" + 1 + 2 + "de");
58+
}
59+
60+
String twoLines() {
61+
return "a concatenation that the formatter pulls onto one line";
62+
}
63+
64+
void escapes() {
65+
String escapes = "tab\there quote\"there backslash\\1";
66+
String maximal = "c\3777d";
67+
String digits = "123\18";
68+
}
69+
70+
void nextToATextBlock() {
71+
String block = """
72+
text
73+
""" + "tail more";
74+
}
75+
76+
@SuppressWarnings("unchecked")
77+
void constants(String key) {
78+
switch (key) {
79+
case "first case" -> {}
80+
default -> {}
81+
}
82+
}
83+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Adjacent string literals on one line stay apart where joining them would lose something or change their text: a
2+
# comment between them, a line comment at the end of their line, which may speak of them as they are written (a table
3+
# with a comment for each row, a NON-NLS marker that counts the literals of its line), an octal escape of one or two
4+
# digits that would take in the digit after it, and a Unicode escape, which javac reads before the escapes of the
5+
# literal, so that it can stand for such a digit or backslash.
6+
----------
7+
class T {
8+
void comments() {
9+
String between = "left " /* keep me here */+"right";
10+
String table = "aa"+ "aar" // Afar
11+
+ "ab" + "abk"; // Abkhazian
12+
log("first " + "half", "second "+"half"); // the call logs two halves
13+
}
14+
15+
void shortOctalEscapes() {
16+
String octal = "a" + "\1"+ "2" + "b";
17+
String twoDigits = "\12"+"3" + "\0" + "0";
18+
}
19+
20+
void unicodeEscapes() {
21+
String backslash = "\u005c1"+ "2";
22+
String digit = "\1" + "\u0032";
23+
}
24+
}
25+
----------
26+
class T {
27+
void comments() {
28+
String between = "left " /* keep me here */ + "right";
29+
String table = "aa" + "aar" // Afar
30+
+ "ab" + "abk"; // Abkhazian
31+
log("first " + "half", "second " + "half"); // the call logs two halves
32+
}
33+
34+
void shortOctalEscapes() {
35+
String octal = "a\1" + "2b";
36+
String twoDigits = "\12" + "3\0" + "0";
37+
}
38+
39+
void unicodeEscapes() {
40+
String backslash = "\u005c1" + "2";
41+
String digit = "\1" + "\u0032";
42+
}
43+
}

‎open-java-format/src/test/resources/test-cases/testdata/NON-NLS-cr.case‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
----------package test;class MultilineStringConstant { // NON-NLS comments are required for i18n, it's important they are kept with their strings. private static final String MULTIPLE_LINE_NON_NLS = "field_0," + //$NON-NLS-1$ "field_1," + //$NON-NLS-1$ "field_2," + //$NON-NLS-1$ "field_3," + //$NON-NLS-1$ "field_4"; //$NON-NLS-1$ private static final String MULTIPLE_LINE_NO_COMMENT = "field_0," + "field_1," + "field_2," + "field_3," + "field_4";}----------package test;class MultilineStringConstant { // NON-NLS comments are required for i18n, it's important they are kept with their strings. private static final String MULTIPLE_LINE_NON_NLS = "field_0," //$NON-NLS-1$ + "field_1," //$NON-NLS-1$ + "field_2," //$NON-NLS-1$ + "field_3," //$NON-NLS-1$ + "field_4"; //$NON-NLS-1$ private static final String MULTIPLE_LINE_NO_COMMENT = "field_0," + "field_1," + "field_2," + "field_3," + "field_4";}
1+
----------package test;class MultilineStringConstant { // NON-NLS comments are required for i18n, it's important they are kept with their strings. private static final String MULTIPLE_LINE_NON_NLS = "field_0," + //$NON-NLS-1$ "field_1," + //$NON-NLS-1$ "field_2," + //$NON-NLS-1$ "field_3," + //$NON-NLS-1$ "field_4"; //$NON-NLS-1$ private static final String MULTIPLE_LINE_NO_COMMENT = "field_0," + "field_1," + "field_2," + "field_3," + "field_4";}----------package test;class MultilineStringConstant { // NON-NLS comments are required for i18n, it's important they are kept with their strings. private static final String MULTIPLE_LINE_NON_NLS = "field_0," //$NON-NLS-1$ + "field_1," //$NON-NLS-1$ + "field_2," //$NON-NLS-1$ + "field_3," //$NON-NLS-1$ + "field_4"; //$NON-NLS-1$ private static final String MULTIPLE_LINE_NO_COMMENT = "field_0,field_1,field_2,field_3,field_4";}

‎open-java-format/src/test/resources/test-cases/testdata/NON-NLS-crlf.case‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,5 @@ class MultilineStringConstant {
3131
+ "field_3," //$NON-NLS-1$
3232
+ "field_4"; //$NON-NLS-1$
3333

34-
private static final String MULTIPLE_LINE_NO_COMMENT =
35-
"field_0," + "field_1," + "field_2," + "field_3," + "field_4";
34+
private static final String MULTIPLE_LINE_NO_COMMENT = "field_0,field_1,field_2,field_3,field_4";
3635
}

‎open-java-format/src/test/resources/test-cases/testdata/NON-NLS-lf.case‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,5 @@ class MultilineStringConstant {
3131
+ "field_3," //$NON-NLS-1$
3232
+ "field_4"; //$NON-NLS-1$
3333

34-
private static final String MULTIPLE_LINE_NO_COMMENT =
35-
"field_0," + "field_1," + "field_2," + "field_3," + "field_4";
34+
private static final String MULTIPLE_LINE_NO_COMMENT = "field_0,field_1,field_2,field_3,field_4";
3635
}

0 commit comments

Comments
 (0)