Skip to content

Commit da784aa

Browse files
ZayanKhan-12abashev
authored andcommitted
Join adjacent string literals that share a line
When a concatenation of string literals ended up on one line, the + between them stayed, as in "one " + "two". The string wrapping pass only looked at lines past the column limit, so the output depended on where the author had broken the string. StringWrapper now joins each run of adjacent string literals that share a line into one literal, whether the author wrote them so or the layout brought them together, and the code is laid out again around it. A line with a quote on both sides of a + now sets the pass off, and the pass returns at once when it finds nothing to do. Literals stay apart where joining would lose or change something: on different lines, which is wrapping done on purpose; with a comment between them; at a text block; on a line that ends in a line comment, which may speak of them as they are written (a NON-NLS marker counts the literals of its line, and a table of literals has a comment for each row); where the left one ends in an octal escape of one or two digits and the right one starts with an octal digit ("\1" + "2" is two characters, "\12" one); and where either holds a Unicode escape. A run that a reflowed chain or a text block overlaps is left to that pass. From palantir#1781 by Zayan Khan, closed there unmerged, which addresses palantir#68. Adapted while bringing it over: the line comment rule and the Unicode escape rule are new. Without the first, java/util/LocaleISOData.java of the JDK, a table of rows such as "aa" + "aar" // Afar, became a column of "aaaar" // Afar, and the NON-NLS markers of #23 lost their literals. Without the second, "\u005c1" + "2" became "\u005c12", one character instead of two, which the AST check rejected, so the file failed to format. The goldens and unit tests became two cases, the expected outputs of NON-NLS and palantir-10 join their literals, and the changelog entry is left out. Of the 15,747 files of the JDK 21 sources, 787 format differently, in 2,303 places, and each is a join: with the + between two literals taken out and blanks ignored, both versions read the same. 737 files join on lines that stay as they are; in 167 the code is then laid out again, mostly a call whose argument now fits on the line of the call. The files that change on a second run go down from 67 to 48, none of them new: in 19, a second run moved the pieces of a chain about that are now joined in the first. Tests on JDK 21: FormatterCasesTest 887 -> 889; 1410 tests in the module, 1471 in all modules. Fixes #148.
1 parent 6bcacfc commit da784aa

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)