diff --git a/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtStyleTest.java b/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtStyleTest.java index 7e448cc..d6eeade 100644 --- a/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtStyleTest.java +++ b/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtStyleTest.java @@ -20,6 +20,77 @@ class JfmtStyleTest { + @Test + void keepsAdjacentBlockCommentsSeparatedAndStable() { + String input = + """ + /* + * License. + */ + + /** Type documentation. */ + class Example {} + """; + + String formatted = JfmtTestSupport.format(input); + + assertEquals( + """ + /* + * License. + */ + /** Type documentation. */ + class Example {} + """, + formatted); + assertEquals(formatted, JfmtTestSupport.format(formatted)); + } + + @Test + void leavesAdjacentCommentMarkersInsideStringsUntouched() { + String input = "class Example {\n" + + "String value = \"*//**\";\n" + + "String text = \"\"\"\n" + + "*//**\n" + + "\"\"\";\n" + + "}\n"; + + String formatted = JfmtTestSupport.format(input); + + assertEquals(2, formatted.split("\\*//\\*\\*", -1).length - 1); + assertEquals(formatted, JfmtTestSupport.format(formatted)); + } + + @Test + void keepsLongSwitchArmsStable() { + String input = + """ + final class MavenCommand { + private final ToolRuntime tools; + private final List definitions; + private final ResolutionOptions javadocOptions; + + int run(JaInvocation commandLine, ModuleSourcePath moduleSourcePath, InputStream in, + PrintStream out, PrintStream err) + throws IOException { + if (commandLine.toolArguments().isEmpty()) { + throw new IllegalArgumentException("maven requires an operation: export, install, deploy, or deploy-central"); + } + return switch (commandLine.toolArguments().getFirst()) { + case "export" -> new MavenExporter(tools).run(commandLine, moduleSourcePath, in, out, err); + case "install", "deploy", "deploy-central" -> new MavenDeploymentCommand(tools, definitions, + javadocOptions).run(commandLine, moduleSourcePath, in, out, err); + default -> throw new IllegalArgumentException("Unknown maven operation: " + commandLine.toolArguments().getFirst()); + }; + } + } + """; + + String formatted = JfmtTestSupport.format(input); + + assertEquals(formatted, JfmtTestSupport.format(formatted)); + } + @Test void placesStaticImportsAfterOrdinaryImports() { String input = "import static java.util.Collections.emptyList;import sun.misc.Unsafe;import java.util.List;import jdk.internal.misc.VM;class Example{List values=emptyList();Unsafe unsafe;Class vm=VM.class;}"; diff --git a/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtToolTest.java b/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtToolTest.java index 50ce303..97f2735 100644 --- a/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtToolTest.java +++ b/src/com.netflix.tools.jfmt.test/com/netflix/tools/jfmt/test/JfmtToolTest.java @@ -100,6 +100,36 @@ void expandsArgumentFiles() throws Exception { assertEquals("class Example {\n int value;\n}\n", Files.readString(source)); } + @Test + void formatsMultipleFilesIdempotently() throws Exception { + var files = new String[64]; + for (int i = 0; i < files.length; i++) { + Path source = write( + temporaryDirectory.resolve("Example" + i + ".java"), + """ + class Example%d { + int run(String operation) { + return switch (operation) { + case "install", "deploy", "deploy-central" -> new MavenDeploymentCommand(tools, definitions, javadocOptions) + .run(commandLine, moduleSourcePath, in, out, err); + default -> 0; + }; + } + } + """.formatted(i)); + files[i] = source.toString(); + } + + Invocation formatted = run("", files); + var checkArguments = new String[files.length + 1]; + checkArguments[0] = "--check"; + System.arraycopy(files, 0, checkArguments, 1, files.length); + Invocation checked = run("", checkArguments); + + assertEquals(0, formatted.exitCode(), formatted.error()); + assertEquals(0, checked.exitCode(), checked.error()); + } + @Test void acceptsOptionsAfterFileOperands() throws Exception { Path source = write(temporaryDirectory.resolve("Example.java"), "class Example{int value;}"); diff --git a/src/com.netflix.tools.jfmt/classes/com/netflix/tools/jfmt/JfmtFormatter.java b/src/com.netflix.tools.jfmt/classes/com/netflix/tools/jfmt/JfmtFormatter.java index ae17338..43f0c98 100644 --- a/src/com.netflix.tools.jfmt/classes/com/netflix/tools/jfmt/JfmtFormatter.java +++ b/src/com.netflix.tools.jfmt/classes/com/netflix/tools/jfmt/JfmtFormatter.java @@ -41,7 +41,83 @@ public JfmtFormatter() { * Format a Java source file, reorder imports, and remove unused imports. */ public String formatSource(String input) throws FormatterException { - return formatter.formatSourceAndFixImports(input); + String formatted = separateAdjacentBlockComments(formatter.formatSourceAndFixImports(input)); + for (int pass = 0; pass < 8; pass++) { + String next = separateAdjacentBlockComments(formatter.formatSource(formatted)); + if (next.equals(formatted)) { + return formatted; + } + formatted = next; + } + throw new FormatterException("Formatting did not converge"); + } + + private static String separateAdjacentBlockComments(String input) { + var output = new StringBuilder(input.length()); + String lineSeparator = input.contains("\r\n") ? "\r\n" : "\n"; + var state = LexicalState.NORMAL; + for (int i = 0; i < input.length();) { + if (state == LexicalState.NORMAL && input.startsWith("\"\"\"", i)) { + output.append("\"\"\""); + i += 3; + state = LexicalState.TEXT_BLOCK; + } else if (state == LexicalState.NORMAL && input.startsWith("//", i)) { + output.append("//"); + i += 2; + state = LexicalState.LINE_COMMENT; + } else if (state == LexicalState.NORMAL && input.startsWith("/*", i)) { + output.append("/*"); + i += 2; + state = LexicalState.BLOCK_COMMENT; + } else if (state == LexicalState.NORMAL && input.charAt(i) == '"') { + output.append('"'); + i++; + state = LexicalState.STRING; + } else if (state == LexicalState.NORMAL && input.charAt(i) == '\'') { + output.append('\''); + i++; + state = LexicalState.CHARACTER; + } else if ((state == LexicalState.STRING || state == LexicalState.CHARACTER || state == LexicalState.TEXT_BLOCK) + && input.charAt(i) == '\\' + && i + 1 < input.length()) { + output.append(input, i, i + 2); + i += 2; + } else if (state == LexicalState.STRING && input.charAt(i) == '"') { + output.append('"'); + i++; + state = LexicalState.NORMAL; + } else if (state == LexicalState.CHARACTER && input.charAt(i) == '\'') { + output.append('\''); + i++; + state = LexicalState.NORMAL; + } else if (state == LexicalState.TEXT_BLOCK && input.startsWith("\"\"\"", i)) { + output.append("\"\"\""); + i += 3; + state = LexicalState.NORMAL; + } else if (state == LexicalState.LINE_COMMENT && (input.charAt(i) == '\n' || input.charAt(i) == '\r')) { + output.append(input.charAt(i++)); + state = LexicalState.NORMAL; + } else if (state == LexicalState.BLOCK_COMMENT && input.startsWith("*/", i)) { + output.append("*/"); + i += 2; + state = LexicalState.NORMAL; + if (input.startsWith("/*", i)) { + output.append(lineSeparator); + } + } else { + output.append(input.charAt(i++)); + } + } + return output.toString(); + } + + private enum LexicalState { + NORMAL, + STRING, + CHARACTER, + TEXT_BLOCK, + LINE_COMMENT, + BLOCK_COMMENT } /** Format specified character ranges of a Java source file. */