Skip to content

Commit 53bea7f

Browse files
asm0deyclaude
andcommitted
fix: render each import declaration instead of rebuilding it
ImportOrderer threw away the text of each import and re-synthesized it from the kind and the name, so every comment in the declaration had to be relocated to wherever that synthesized string could hold it -- after the semicolon -- and anything that did not reduce to those two pieces was rejected: import module java . base; error: Expected ; after import import module java./* why */base; error: Could not parse imported name, at: /* why */ Both compile with `javac --release 25`, and `formatSource` formats both; only the import-fixing entry points failed, which the Gradle plugin's Spotless step reaches through FormatterService. The same cause made the earlier comment handling a patch on a symptom: a relocated comment is a comment the author has to find again. The declaration is now rendered once while it is scanned, normalizing the whitespace -- one import per line is the style -- and leaving each comment in the slot it was written in. That removes the comment list, the line-terminator juggling around it, and the special case for a `//` comment inside a declaration. Imports that compare equal still collapse into one, but the comparator now breaks ties on the rendered declaration, so two imports of the same name that are written differently are both kept. Before, the second one vanished, and any comment it carried vanished with it. Consequences worth noting in review: `import com . foo . Second ;`, which an existing row recorded as "syntactically valid, but we don't support it", now formats; and a duplicate that differs only by a comment is no longer deduplicated. A duplicate that differs only by a trailing comment after the semicolon still loses it, as on develop. Javadoc inside or trailing an import is still rejected: the formatter moves a javadoc comment onto a line of its own, which would separate the imports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2e6822f commit 53bea7f

3 files changed

Lines changed: 163 additions & 64 deletions

File tree

‎palantir-java-format/src/main/java/com/palantir/javaformat/java/ImportOrderer.java‎

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ private String reorderImports() throws FormatterException {
130130
private static final Comparator<Import> GOOGLE_IMPORT_COMPARATOR = Comparator.comparing(
131131
Import::isStatic, trueFirst())
132132
.thenComparing(Import::isModule, trueFirst())
133-
.thenComparing(Import::imported);
133+
.thenComparing(Import::imported)
134+
// Imports that compare equal collapse into one, so two that are written differently -- one of them
135+
// carrying a comment, say -- must not compare equal, or that comment disappears with it.
136+
.thenComparing(Import::declaration);
134137

135138
/**
136139
* A {@link Comparator} that orders {@link Import}s by AOSP Style, defined at
@@ -144,7 +147,8 @@ private String reorderImports() throws FormatterException {
144147
.thenComparing(Import::isAndroid, trueFirst())
145148
.thenComparing(Import::isThirdParty, trueFirst())
146149
.thenComparing(Import::isJava, trueFirst())
147-
.thenComparing(Import::imported);
150+
.thenComparing(Import::imported)
151+
.thenComparing(Import::declaration);
148152

149153
/**
150154
* Determines whether to insert a blank line between the {@code prev} and {@code curr} {@link Import}s based on
@@ -196,14 +200,22 @@ class Import {
196200
private final boolean isStatic;
197201
private final boolean isModule;
198202
private final String trailing;
199-
private final ImmutableList<String> comments;
203+
private final String declaration;
200204

201-
Import(String imported, String trailing, boolean isStatic, boolean isModule, ImmutableList<String> comments) {
205+
Import(String imported, String trailing, boolean isStatic, boolean isModule, String declaration) {
202206
this.imported = imported;
203207
this.trailing = trailing;
204208
this.isStatic = isStatic;
205209
this.isModule = isModule;
206-
this.comments = comments;
210+
this.declaration = declaration;
211+
}
212+
213+
/**
214+
* The declaration as it will be written: the {@code import} keyword through the semicolon, with whitespace
215+
* normalized and any comments left in the slot the author wrote them in.
216+
*/
217+
String declaration() {
218+
return declaration;
207219
}
208220

209221
/** The name being imported, for example {@code java.util.List}. */
@@ -262,45 +274,52 @@ public boolean isThirdParty() {
262274
@Override
263275
public String toString() {
264276
StringBuilder sb = new StringBuilder();
265-
sb.append("import ");
266-
if (isModule()) {
267-
sb.append("module ");
268-
} else if (isStatic()) {
269-
sb.append("static ");
270-
}
271-
sb.append(imported()).append(';');
272-
StringBuilder tail = new StringBuilder();
273-
for (String comment : comments) {
274-
if (!endsInNewline(tail)) {
275-
tail.append(' ');
276-
}
277-
tail.append(comment);
278-
if (comment.startsWith("//")) {
279-
// A // comment swallows the rest of its line, so nothing may follow it there.
280-
tail.append(lineSeparator);
281-
}
282-
}
283-
String trailingText = trailing();
284-
if (endsInNewline(tail)) {
285-
// Don't double the line terminator the trailing text already starts with.
286-
int newline = Newlines.hasNewlineAt(trailingText, 0);
287-
if (newline > 0) {
288-
trailingText = trailingText.substring(newline);
289-
}
290-
}
291-
tail.append(trailingText);
292-
if (tail.toString().trim().isEmpty()) {
277+
sb.append(declaration());
278+
if (trailing().trim().isEmpty()) {
293279
sb.append(lineSeparator);
294280
} else {
295-
sb.append(tail);
296-
if (!endsInNewline(tail)) {
297-
sb.append(lineSeparator);
298-
}
281+
sb.append(trailing());
299282
}
300283
return sb.toString();
301284
}
302285
}
303286

287+
/**
288+
* Renders one import declaration from the toks it is made of. Whitespace between the toks is normalized, since the
289+
* style guide puts one import on a line of its own, but a comment stays in the slot the author wrote it in rather
290+
* than being moved to wherever the rendered declaration can accommodate it.
291+
*/
292+
private final class Declaration {
293+
private final StringBuilder text = new StringBuilder();
294+
private boolean atLineStart = false;
295+
296+
/** Appends one tok: a keyword, an identifier, {@code .}, {@code *}, {@code ;}, or a comment. */
297+
void append(String piece) {
298+
if (text.length() > 0 && !atLineStart && needsSpaceBefore(piece)) {
299+
text.append(' ');
300+
}
301+
text.append(piece);
302+
atLineStart = false;
303+
if (piece.startsWith("//")) {
304+
// A // comment swallows the rest of its line, so the declaration continues on the next one.
305+
text.append(lineSeparator);
306+
atLineStart = true;
307+
}
308+
}
309+
310+
private boolean needsSpaceBefore(String piece) {
311+
if (piece.equals(".") || piece.equals(";") || piece.equals("*")) {
312+
return false;
313+
}
314+
return text.charAt(text.length() - 1) != '.';
315+
}
316+
317+
@Override
318+
public String toString() {
319+
return text.toString();
320+
}
321+
}
322+
304323
private String tokString(int start, int end) {
305324
StringBuilder sb = new StringBuilder();
306325
for (int i = start; i < end; i++) {
@@ -341,31 +360,33 @@ private ImportsAndIndex scanImports(int i) throws FormatterException {
341360
// of our tests here and protects us from running off the end of the toks list. Since it is
342361
// zero-width it doesn't matter if we include it in our string concatenation at the end.
343362
while (i < toks.size() && tokenAt(i).equals("import")) {
363+
Declaration declaration = new Declaration();
364+
declaration.append(tokenAt(i));
344365
i++;
345-
// Comments between the tokens of the import are collected and re-emitted after the
346-
// semicolon, so nothing is dropped.
347-
List<String> comments = new ArrayList<>();
348-
i = skipIgnored(i, comments);
366+
i = skipIgnored(i, declaration);
349367
boolean isModule = isModuleKeyword(i);
350368
if (isModule) {
369+
declaration.append(tokenAt(i));
351370
i++;
352-
i = skipIgnored(i, comments);
371+
i = skipIgnored(i, declaration);
353372
}
354373
boolean isStatic = !isModule && tokenAt(i).equals("static");
355374
if (isStatic) {
375+
declaration.append(tokenAt(i));
356376
i++;
357-
i = skipIgnored(i, comments);
377+
i = skipIgnored(i, declaration);
358378
}
359379
if (!isIdentifierToken(i)) {
360380
throw new FormatterException("Unexpected token after import: " + tokenAt(i));
361381
}
362-
StringAndIndex imported = scanImported(i);
382+
StringAndIndex imported = scanImported(i, declaration);
363383
String importedName = imported.string;
364384
i = imported.index;
365-
i = skipIgnored(i, comments);
385+
i = skipIgnored(i, declaration);
366386
if (!tokenAt(i).equals(";")) {
367387
throw new FormatterException("Expected ; after import");
368388
}
389+
declaration.append(";");
369390
while (tokenAt(i).equals(";")) {
370391
// Extra semicolons are not allowed by the JLS but are accepted by javac.
371392
i++;
@@ -393,8 +414,7 @@ private ImportsAndIndex scanImports(int i) throws FormatterException {
393414
i++;
394415
}
395416
}
396-
imports.add(
397-
new Import(importedName, trailing.toString(), isStatic, isModule, ImmutableList.copyOf(comments)));
417+
imports.add(new Import(importedName, trailing.toString(), isStatic, isModule, declaration.toString()));
398418
// Remember the position just after the import we just saw, before skipping blank lines.
399419
// If the next thing after the blank lines is not another import then we don't want to
400420
// include those blank lines in the text to be replaced.
@@ -437,32 +457,39 @@ private static class StringAndIndex {
437457
}
438458

439459
/**
440-
* Scans the imported thing, the dot-separated name that comes after import [static] and before the semicolon. We
441-
* don't allow spaces inside the dot-separated name. Wildcard imports are supported: if the input is {@code import
442-
* java.util.*;} then the returned string will be {@code java.util.*}.
460+
* Scans the imported thing, the dot-separated name that comes after import [static] and before the semicolon.
461+
* Whitespace, line terminators and comments may appear between its parts, as they may anywhere else in the
462+
* declaration; the returned name contains none of them. Wildcard imports are supported: if the input is
463+
* {@code import java.util.*;} then the returned string will be {@code java.util.*}.
443464
*
444465
* @param start the index of the start of the identifier. If the import is {@code import java.util.List;} then this
445466
* index points to the token {@code java}.
467+
* @param declaration collects the toks scanned, so a comment between the parts of the name keeps its place
446468
* @return the parsed import ({@code java.util.List} in the example) and the index of the first token after the
447469
* imported thing ({@code ;} in the example).
448470
* @throws FormatterException if the imported name could not be parsed.
449471
*/
450-
private StringAndIndex scanImported(int start) throws FormatterException {
472+
private StringAndIndex scanImported(int start, Declaration declaration) throws FormatterException {
451473
int i = start;
452474
StringBuilder imported = new StringBuilder();
453475
// At the start of each iteration of this loop, i points to an identifier.
454476
// On exit from the loop, i points to a token after an identifier or after *.
455477
while (true) {
456478
Preconditions.checkState(isIdentifierToken(i));
457479
imported.append(tokenAt(i));
480+
declaration.append(tokenAt(i));
458481
i++;
482+
i = skipIgnored(i, declaration);
459483
if (!tokenAt(i).equals(".")) {
460484
return new StringAndIndex(imported.toString(), i);
461485
}
462486
imported.append('.');
487+
declaration.append(".");
463488
i++;
489+
i = skipIgnored(i, declaration);
464490
if (tokenAt(i).equals("*")) {
465491
imported.append('*');
492+
declaration.append("*");
466493
return new StringAndIndex(imported.toString(), i + 1);
467494
} else if (!isIdentifierToken(i)) {
468495
throw new FormatterException("Could not parse imported name, at: " + tokenAt(i));
@@ -514,21 +541,21 @@ private boolean isModuleKeyword(int i) {
514541
if (!tokenAt(i).equals("module")) {
515542
return false;
516543
}
517-
return isIdentifierToken(skipIgnored(i + 1, new ArrayList<>()));
544+
return isIdentifierToken(skipIgnored(i + 1, new Declaration()));
518545
}
519546

520547
/**
521-
* Skips whitespace, line terminators and comments starting at {@code i}, appending the text of each comment to
522-
* {@code comments}, and returns the index of the first token that is none of those. Javadoc comments are not
523-
* skipped: the formatter moves them onto a line of their own, so an import carrying one is rejected, as it was
524-
* before module imports were supported.
548+
* Skips whitespace, line terminators and comments starting at {@code i}, appending each comment to
549+
* {@code declaration}, and returns the index of the first token that is none of those. Javadoc comments are not
550+
* skipped: the formatter moves them onto a line of their own, which would separate the imports, so an import
551+
* carrying one is rejected, as it was before module imports were supported.
525552
*/
526-
private int skipIgnored(int i, List<String> comments) {
553+
private int skipIgnored(int i, Declaration declaration) {
527554
while (i < toks.size()) {
528555
if (isSpaceToken(i) || isNewlineToken(i)) {
529556
i++;
530557
} else if (isSlashSlashCommentToken(i) || isBlockCommentToken(i)) {
531-
comments.add(tokenAt(i).trim());
558+
declaration.append(tokenAt(i).trim());
532559
i++;
533560
} else {
534561
break;

‎palantir-java-format/src/test/java/com/palantir/javaformat/java/GoogleImportStyleTest.java‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -453,13 +453,15 @@ public static List<Object[]> parameters() {
453453
"*/",
454454
}
455455
},
456+
// Whitespace may appear between the parts of a qualified name; it is normalized away.
456457
{
457458
{
458-
"import com . foo . Second ;", // syntactically valid, but we don't support it
459+
"import com . foo . Second ;", //
459460
"import com.foo.First;",
460461
},
461462
{
462-
"!!Expected ; after import",
463+
"import com.foo.First;", //
464+
"import com.foo.Second;",
463465
}
464466
},
465467
{
@@ -613,7 +615,7 @@ public static List<Object[]> parameters() {
613615
{
614616
"package foo;",
615617
"",
616-
"import module java.base; /* the base module */",
618+
"import module /* the base module */ java.base;",
617619
"import module java.desktop;",
618620
"",
619621
"public class Blim {}",
@@ -635,15 +637,50 @@ public static List<Object[]> parameters() {
635637
{
636638
"package foo;",
637639
"",
638-
"import static com.foo.First.first; /* a member */",
640+
"import static /* a member */ com.foo.First.first;",
639641
"",
640-
"import com.foo.Second; /* a type */",
642+
"import /* a type */ com.foo.Second;",
641643
"import com.foo.Third;",
642644
"",
643645
"public class Blim {}",
644646
},
645647
},
646648

649+
// A comment may also sit between the parts of the name, and stays there. Only the
650+
// whitespace around it is normalized.
651+
{
652+
{
653+
"package foo;", "", "import com.foo./* the second one */Second;", "", "public class Blim {}",
654+
},
655+
{
656+
"package foo;", "", "import com.foo./* the second one */ Second;", "", "public class Blim {}",
657+
},
658+
},
659+
660+
// Identical declarations collapse into one; ones that differ are both kept, so that a
661+
// comment does not disappear with the copy that goes.
662+
{
663+
{
664+
"package foo;",
665+
"",
666+
"import com.foo.First;",
667+
"import com.foo.First;",
668+
"import /* explanation A */ com.foo.Second;",
669+
"import /* explanation B */ com.foo.Second;",
670+
"",
671+
"public class Blim {}",
672+
},
673+
{
674+
"package foo;",
675+
"",
676+
"import com.foo.First;",
677+
"import /* explanation A */ com.foo.Second;",
678+
"import /* explanation B */ com.foo.Second;",
679+
"",
680+
"public class Blim {}",
681+
},
682+
},
683+
647684
// A package literally named `module` is an ordinary import, not a module import:
648685
// `module` only introduces one when another identifier follows it.
649686
{

‎palantir-java-format/src/test/java/com/palantir/javaformat/java/ModuleImportTest.java‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,42 @@ public void fixesImportsOnlyFromTheCommandLine() throws Exception {
7575
@Test
7676
public void keepsACommentBetweenModuleAndTheModuleName() throws FormatterException {
7777
String input = "import module /* comment */ java.base;\n" + "class Example {}\n";
78-
String expected = "import module java.base; /* comment */\n" + "\n" + "class Example {}\n";
78+
String expected = "import module /* comment */ java.base;\n" + "\n" + "class Example {}\n";
79+
assertFormats(input, expected);
80+
}
81+
82+
@Test
83+
public void keepsACommentBetweenThePartsOfTheModuleName() throws FormatterException {
84+
String input = "import module java./* comment */base;\n" + "class Example {}\n";
85+
// Reordering normalizes the whitespace around the comment and leaves it between the parts.
86+
String reordered = "import module java./* comment */ base;\n" + "\n" + "class Example {}\n";
87+
assertThat(Formatter.create().fixImports(input)).isEqualTo(reordered);
88+
assertThat(Formatter.create().fixImports(reordered)).isEqualTo(reordered);
89+
90+
// Formatting then breaks the line after the dot, which is where the formatter puts a comment
91+
// in a qualified name; that output is stable too.
92+
String formatted = "import module java.\n" + "/* comment */ base;\n" + "\n" + "class Example {}\n";
93+
assertThat(Formatter.create().formatSourceAndFixImports(input)).isEqualTo(formatted);
94+
assertThat(Formatter.create().formatSourceAndFixImports(formatted)).isEqualTo(formatted);
95+
}
96+
97+
@Test
98+
public void normalizesWhitespaceInsideTheModuleName() throws FormatterException {
99+
String input = "import module java . base;\n" + "class Example {}\n";
100+
String expected = "import module java.base;\n" + "\n" + "class Example {}\n";
101+
assertFormats(input, expected);
102+
}
103+
104+
@Test
105+
public void keepsBothCopiesOfADuplicateThatCarriesAComment() throws FormatterException {
106+
// Identical declarations collapse; ones that differ are both kept, so no comment is dropped.
107+
String input = "import module /* explanation A */ java.base;\n"
108+
+ "import module /* explanation B */ java.base;\n"
109+
+ "class Example {}\n";
110+
String expected = "import module /* explanation A */ java.base;\n"
111+
+ "import module /* explanation B */ java.base;\n"
112+
+ "\n"
113+
+ "class Example {}\n";
79114
assertFormats(input, expected);
80115
}
81116

0 commit comments

Comments
 (0)