5353import java .util .stream .Collectors ;
5454import 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 . */
5757public 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 )) {
0 commit comments