diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5c62b6298..e6fa6fe02 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,6 +37,68 @@ jobs:
annotate_only: true
job_summary: true
+ # The tests of `build` on the newer JDKs, in parallel with everything else: nothing waits for these
+ # jobs and nothing they build is kept. The code is still compiled for Java 21 by JDK 21, as in the
+ # release; -PjavaRuntime moves only the test JVMs, and with them the javac whose internals the
+ # formatter parses with. The IntelliJ plugin's tests stay on the runtime of the IDE they start.
+ # JDK 21 itself is `build`, the check the main branch requires.
+ jdk:
+ name: build (JDK ${{ matrix.jdk }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - jdk: 25
+ gradle_args: ''
+ gradle_args_reason: ''
+ - jdk: 26
+ gradle_args: ''
+ gradle_args_reason: ''
+ - jdk: 27
+ gradle_args: -x :gradle-open-java-format:test
+ gradle_args_reason: >-
+ Gradle 9.7.1 does not run on Java 27 yet, and the Gradle plugin's tests run their TestKit builds on
+ the test JVM, where they fail with "Unsupported class file major version 71". Those tests are left
+ out until the wrapper's Gradle supports Java 27.
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Install JDK ${{ matrix.jdk }} for the tests
+ id: test-jdk
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.jdk }}
+
+ # Installed last, so that it is the JAVA_HOME Gradle runs and compiles on.
+ - name: Install JDK 21
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ with:
+ distribution: temurin
+ java-version: '21'
+
+ - name: Explain the extra Gradle arguments
+ if: ${{ matrix.gradle_args != '' }}
+ env:
+ GRADLE_ARGS: ${{ matrix.gradle_args }}
+ REASON: ${{ matrix.gradle_args_reason }}
+ run: echo "::notice title=JDK ${{ matrix.jdk }} runs with $GRADLE_ARGS::$REASON"
+
+ # Gradle does not look into the runner's tool cache, so it is told where the test JDK is.
+ - name: Build
+ run: >-
+ ./gradlew test -PjavaRuntime=${{ matrix.jdk }} ${{ matrix.gradle_args }}
+ -Porg.gradle.java.installations.paths=${{ steps.test-jdk.outputs.path }}
+
+ - name: Publish Test Report
+ uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
+ if: ${{ !cancelled() }}
+ with:
+ report_paths: '**/build/test-results/test/*.xml'
+ annotate_only: true
+ job_summary: true
+
# One explicit job per target, so every binary we ship is traceable to a named run.
native:
name: native (${{ matrix.platform }})
@@ -87,7 +149,8 @@ jobs:
env:
JDK21_HOME: ${{ steps.jdk21.outputs.path }}
- # The binary itself on a file that needs formatting, a formatted one and one that does not parse.
+ # The binary itself on a file that needs formatting, a formatted one, one that does not parse and
+ # one in Java 25 syntax.
- name: Smoke-test the binary
run: |
binary="$PWD/$(ls open-java-format-native/build/native/nativeCompile/open-java-format-* | grep -v '\.txt$')"
@@ -100,6 +163,10 @@ jobs:
printf 'class B {\n' > B.java
set +e; "$binary" B.java; status=$?; set -e
test "$status" -eq 2
+ # Java 25 syntax: a compact source file with a module import and an unnamed pattern.
+ printf 'import module java.base;\nrecord Box(int a,int b){}\nvoid main(){Object o=new Box(1,2);if(o instanceof Box(_,_)){IO.println(List.of(1));}}\n' > C.java
+ "$binary" --replace C.java
+ printf 'import module java.base;\n\nrecord Box(int a, int b) {}\n\nvoid main() {\n Object o = new Box(1, 2);\n if (o instanceof Box(_, _)) {\n IO.println(List.of(1));\n }\n}\n' | diff - C.java
- name: Test the plugins against the image
run: ./gradlew -PnativeImage=true :open-java-format-jdk-bootstrap:test :gradle-open-java-format:test
diff --git a/README.md b/README.md
index c96fb2a74..8ce3d0a03 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,10 @@ mise trust && mise install
./gradlew test # what the CI build job runs
```
+`-PjavaRuntime=25` runs the same tests on JDK 25, as CI's `jdk` jobs do for 25, 26 and 27. The code
+is compiled for Java 21 either way. Gradle has to find that JDK: installed with mise, or named with
+`-Porg.gradle.java.installations.paths=/path/to/jdk`.
+
Nothing inside the build downloads a JDK. `gradle.properties` turns toolchain auto-download off and
reads the installations from `JDK21_HOME` and `GRAALVM_HOME`, so a missing JDK is an error you can
read rather than a silent download.
diff --git a/build.gradle b/build.gradle
index a73a45132..566cb7961 100644
--- a/build.gradle
+++ b/build.gradle
@@ -87,6 +87,9 @@ allprojects {
tasks.withType(Test).configureEach {
jvmArgs(javacInternalExports.collect { "--add-exports=${it}=ALL-UNNAMED".toString() })
+ // The short form gave CI logs only "FormatterException at FormatterIntegrationTest.java:76", never what
+ // the formatter complained about, nor the output of a TestKit build that failed.
+ testLogging.exceptionFormat = 'full'
}
tasks.withType(Javadoc).configureEach {
@@ -127,7 +130,11 @@ subprojects {
}
}
+// The JDK the tests run on, and with it the javac whose internals the formatter parses with. The code is
+// compiled for 21 whatever this says. CI's `jdk` jobs pass -PjavaRuntime=25 and so on.
+ext.javaRuntime = providers.gradleProperty('javaRuntime').getOrElse('21')
+
javaVersions {
libraryTarget = 21
- runtime = 21
+ runtime = javaRuntime
}
diff --git a/open-java-format-native/src/main/resources/META-INF/native-image/reachability-metadata.json b/open-java-format-native/src/main/resources/META-INF/native-image/reachability-metadata.json
index c40f18794..5fde3bc33 100644
--- a/open-java-format-native/src/main/resources/META-INF/native-image/reachability-metadata.json
+++ b/open-java-format-native/src/main/resources/META-INF/native-image/reachability-metadata.json
@@ -131,6 +131,15 @@
}
]
},
+ {
+ "type": "com.sun.source.tree.ImportTree",
+ "methods": [
+ {
+ "name": "isModule",
+ "parameterTypes": []
+ }
+ ]
+ },
{
"type": "com.sun.tools.javac.parser.JavaTokenizer",
"fields": [
@@ -151,6 +160,28 @@
}
]
},
+ {
+ "type": "com.sun.tools.javac.tree.EndPosTable"
+ },
+ {
+ "type": "com.sun.tools.javac.tree.JCTree",
+ "methods": [
+ {
+ "name": "getEndPosition",
+ "parameterTypes": [
+ "com.sun.tools.javac.tree.EndPosTable"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "com.sun.tools.javac.tree.JCTree$JCCompilationUnit",
+ "fields": [
+ {
+ "name": "endPositions"
+ }
+ ]
+ },
{
"type": "java.io.Serializable"
},
diff --git a/open-java-format/build.gradle b/open-java-format/build.gradle
index 12eb94e2f..d78260236 100644
--- a/open-java-format/build.gradle
+++ b/open-java-format/build.gradle
@@ -76,6 +76,9 @@ tasks.named("test") {
// Run all classes and tests in parallel
// https://junit.org/junit5/docs/current/user-guide/#writing-tests-parallel-execution
systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent'
+ // FormatterVersionTest fails when the tests run on another JDK than the one asked for, so a CI leg
+ // cannot pass on the wrong JDK and skip the tests that need a newer parser.
+ systemProperty 'expectedJavaVersion', rootProject.ext.javaRuntime
}
javaVersion {
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java b/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java
index ef8dab408..b6b78bfda 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/Formatter.java
@@ -184,8 +184,8 @@ public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOExcept
};
Log.instance(context).useSource(source);
ParserFactory parserFactory = ParserFactory.instance(context);
- JavacParser parser = parserFactory.newParser(
- sourceText, /*keepDocComments=*/ true, /*keepEndPos=*/ true, /*keepLineMap=*/ true);
+ JavacParser parser =
+ Trees.newParser(parserFactory, sourceText, /*keepDocComments=*/ true, /*keepLineMap=*/ true);
unit = parser.parseCompilationUnit();
unit.sourcefile = source;
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/ImportOrderer.java b/open-java-format/src/main/java/com/palantir/javaformat/java/ImportOrderer.java
index 4a0fdd46d..873b56a01 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/ImportOrderer.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/ImportOrderer.java
@@ -123,27 +123,42 @@ private String reorderImports() throws FormatterException {
/**
* A {@link Comparator} that orders {@link Import}s by Google Style, defined at
* https://google.github.io/styleguide/javaguide.html#s3.3.3-import-ordering-and-spacing.
+ *
+ *
Google Style does not use module imports ({@code import module foo.bar;}, JEP 511); when present, they
+ * sort between static and non-static type imports, matching google-java-format.
*/
- private static final Comparator GOOGLE_IMPORT_COMPARATOR =
- Comparator.comparing(Import::isStatic, trueFirst()).thenComparing(Import::imported);
+ private static final Comparator GOOGLE_IMPORT_COMPARATOR = Comparator.comparing(
+ Import::isStatic, trueFirst())
+ .thenComparing(Import::isModule, trueFirst())
+ .thenComparing(Import::imported)
+ // Imports that compare equal collapse into one, so two that are written differently -- one of them
+ // carrying a comment inside it or on the lines before it, say -- must not compare equal, or that comment
+ // disappears with it.
+ .thenComparing(Import::declaration)
+ .thenComparing(Import::leading);
/**
* A {@link Comparator} that orders {@link Import}s by AOSP Style, defined at
* https://source.android.com/setup/contribute/code-style#order-import-statements and implemented in IntelliJ at
* https://android.googlesource.com/platform/development/+/master/ide/intellij/codestyles/AndroidStyle.xml.
+ *
+ *
As with {@link #GOOGLE_IMPORT_COMPARATOR}, module imports sort after static imports.
*/
private static final Comparator AOSP_IMPORT_COMPARATOR = Comparator.comparing(Import::isStatic, trueFirst())
+ .thenComparing(Import::isModule, trueFirst())
.thenComparing(Import::isAndroid, trueFirst())
.thenComparing(Import::isThirdParty, trueFirst())
.thenComparing(Import::isJava, trueFirst())
- .thenComparing(Import::imported);
+ .thenComparing(Import::imported)
+ .thenComparing(Import::declaration)
+ .thenComparing(Import::leading);
/**
* Determines whether to insert a blank line between the {@code prev} and {@code curr} {@link Import}s based on
* Google style.
*/
private static boolean shouldInsertBlankLineGoogle(Import prev, Import curr) {
- return prev.isStatic() && !curr.isStatic();
+ return prev.isStatic() != curr.isStatic() || prev.isModule() != curr.isModule();
}
/**
@@ -151,7 +166,7 @@ private static boolean shouldInsertBlankLineGoogle(Import prev, Import curr) {
* style.
*/
private static boolean shouldInsertBlankLineAosp(Import prev, Import curr) {
- if (prev.isStatic() && !curr.isStatic()) {
+ if (prev.isStatic() != curr.isStatic() || prev.isModule() != curr.isModule()) {
return true;
}
// insert blank line between "com.android" from "com.anythingelse"
@@ -186,14 +201,32 @@ private ImportOrderer(String text, ImmutableList toks, Style style) {
class Import {
private final String imported;
private final boolean isStatic;
+ private final boolean isModule;
private final String leading;
private final String trailing;
-
- Import(String imported, String leading, String trailing, boolean isStatic) {
+ private final String declaration;
+
+ Import(
+ String imported,
+ String leading,
+ String trailing,
+ boolean isStatic,
+ boolean isModule,
+ String declaration) {
this.imported = imported;
this.leading = leading;
this.trailing = trailing;
this.isStatic = isStatic;
+ this.isModule = isModule;
+ this.declaration = declaration;
+ }
+
+ /**
+ * The declaration as it will be written: the {@code import} keyword through the semicolon, with whitespace
+ * normalized and any comments left in the slot the author wrote them in.
+ */
+ String declaration() {
+ return declaration;
}
/** The name being imported, for example {@code java.util.List}. */
@@ -206,6 +239,11 @@ boolean isStatic() {
return isStatic;
}
+ /** True if this is {@code import module} (JEP 511). */
+ boolean isModule() {
+ return isModule;
+ }
+
/** The top-level package of the import. */
String topLevel() {
return DOT_SPLITTER.split(imported()).iterator().next();
@@ -257,11 +295,7 @@ public boolean isThirdParty() {
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(leading());
- sb.append("import ");
- if (isStatic()) {
- sb.append("static ");
- }
- sb.append(imported()).append(';');
+ sb.append(declaration());
if (trailing().trim().isEmpty()) {
sb.append(lineSeparator);
} else {
@@ -271,6 +305,42 @@ public String toString() {
}
}
+ /**
+ * Renders one import declaration from the toks it is made of. Whitespace between the toks is normalized, since the
+ * style guide puts one import on a line of its own, but a comment stays in the slot the author wrote it in rather
+ * than being moved to wherever the rendered declaration can accommodate it.
+ */
+ private final class Declaration {
+ private final StringBuilder text = new StringBuilder();
+ private boolean atLineStart = false;
+
+ /** Appends one tok: a keyword, an identifier, {@code .}, {@code *}, {@code ;}, or a comment. */
+ void append(String piece) {
+ if (text.length() > 0 && !atLineStart && needsSpaceBefore(piece)) {
+ text.append(' ');
+ }
+ text.append(piece);
+ atLineStart = false;
+ if (piece.startsWith("//")) {
+ // A // comment swallows the rest of its line, so the declaration continues on the next one.
+ text.append(lineSeparator);
+ atLineStart = true;
+ }
+ }
+
+ private boolean needsSpaceBefore(String piece) {
+ if (piece.equals(".") || piece.equals(";") || piece.equals("*")) {
+ return false;
+ }
+ return text.charAt(text.length() - 1) != '.';
+ }
+
+ @Override
+ public String toString() {
+ return text.toString();
+ }
+ }
+
private String tokString(int start, int end) {
StringBuilder sb = new StringBuilder();
for (int i = start; i < end; i++) {
@@ -294,9 +364,10 @@ private static class ImportsAndIndex {
*
*
*
* The comments before an import are the ones between it and the previous import, so the first import has none: the
@@ -314,47 +385,46 @@ private ImportsAndIndex scanImports(int i) throws FormatterException {
// of our tests here and protects us from running off the end of the toks list. Since it is
// zero-width it doesn't matter if we include it in our string concatenation at the end.
while (i < toks.size() && tokenAt(i).equals("import")) {
+ Declaration declaration = new Declaration();
+ declaration.append(tokenAt(i));
i++;
- if (isSpaceToken(i)) {
+ i = skipIgnored(i, declaration);
+ boolean isModule = isModuleKeyword(i);
+ if (isModule) {
+ declaration.append(tokenAt(i));
i++;
+ i = skipIgnored(i, declaration);
}
- boolean isStatic = tokenAt(i).equals("static");
+ boolean isStatic = !isModule && tokenAt(i).equals("static");
if (isStatic) {
+ declaration.append(tokenAt(i));
i++;
- if (isSpaceToken(i)) {
- i++;
- }
+ i = skipIgnored(i, declaration);
}
if (!isIdentifierToken(i)) {
throw new FormatterException("Unexpected token after import: " + tokenAt(i));
}
- StringAndIndex imported = scanImported(i);
+ StringAndIndex imported = scanImported(i, declaration);
String importedName = imported.string;
i = imported.index;
- if (isSpaceToken(i)) {
- i++;
- }
+ i = skipIgnored(i, declaration);
if (!tokenAt(i).equals(";")) {
throw new FormatterException("Expected ; after import");
}
+ declaration.append(";");
while (tokenAt(i).equals(";")) {
// Extra semicolons are not allowed by the JLS but are accepted by javac.
i++;
}
StringBuilder trailing = new StringBuilder();
- if (isSpaceToken(i)) {
+ // A block comment on the same line as the `;` trails this import and stays with it, as a line comment
+ // there does; one on a later line goes with whatever follows, so only same-line toks are absorbed here.
+ // Javadoc is excluded: the formatter moves a javadoc comment onto a line of its own, so it is taken as
+ // one of the comments below the import from the start.
+ while (isSpaceToken(i) || isBlockCommentToken(i)) {
trailing.append(tokenAt(i));
i++;
}
- // A block comment on the import's own line stays with the import, as a line comment there does.
- if (isBlockCommentToken(i)) {
- trailing.append(tokenAt(i));
- i++;
- if (isSpaceToken(i)) {
- trailing.append(tokenAt(i));
- i++;
- }
- }
if (isNewlineToken(i)) {
trailing.append(tokenAt(i));
i++;
@@ -369,7 +439,8 @@ private ImportsAndIndex scanImports(int i) throws FormatterException {
i++;
}
}
- imports.add(new Import(importedName, leading, trailing.toString(), isStatic));
+ imports.add(
+ new Import(importedName, leading, trailing.toString(), isStatic, isModule, declaration.toString()));
// Remember the position just after the import we just saw, before skipping blank lines.
// If the next thing after the blank lines is not another import then we don't want to
// include those blank lines in the text to be replaced.
@@ -423,17 +494,19 @@ private static class StringAndIndex {
}
/**
- * Scans the imported thing, the dot-separated name that comes after import [static] and before the semicolon. We
- * don't allow spaces inside the dot-separated name. Wildcard imports are supported: if the input is {@code import
- * java.util.*;} then the returned string will be {@code java.util.*}.
+ * Scans the imported thing, the dot-separated name that comes after import [static] and before the semicolon.
+ * Whitespace, line terminators and comments may appear between its parts, as they may anywhere else in the
+ * declaration; the returned name contains none of them. Wildcard imports are supported: if the input is
+ * {@code import java.util.*;} then the returned string will be {@code java.util.*}.
*
* @param start the index of the start of the identifier. If the import is {@code import java.util.List;} then this
* index points to the token {@code java}.
+ * @param declaration collects the toks scanned, so a comment between the parts of the name keeps its place
* @return the parsed import ({@code java.util.List} in the example) and the index of the first token after the
* imported thing ({@code ;} in the example).
* @throws FormatterException if the imported name could not be parsed.
*/
- private StringAndIndex scanImported(int start) throws FormatterException {
+ private StringAndIndex scanImported(int start, Declaration declaration) throws FormatterException {
int i = start;
StringBuilder imported = new StringBuilder();
// At the start of each iteration of this loop, i points to an identifier.
@@ -441,14 +514,19 @@ private StringAndIndex scanImported(int start) throws FormatterException {
while (true) {
Preconditions.checkState(isIdentifierToken(i));
imported.append(tokenAt(i));
+ declaration.append(tokenAt(i));
i++;
+ i = skipIgnored(i, declaration);
if (!tokenAt(i).equals(".")) {
return new StringAndIndex(imported.toString(), i);
}
imported.append('.');
+ declaration.append(".");
i++;
+ i = skipIgnored(i, declaration);
if (tokenAt(i).equals("*")) {
imported.append('*');
+ declaration.append("*");
return new StringAndIndex(imported.toString(), i + 1);
} else if (!isIdentifierToken(i)) {
throw new FormatterException("Could not parse imported name, at: " + tokenAt(i));
@@ -488,6 +566,41 @@ private String tokenAt(int i) {
return toks.get(i).getOriginalText();
}
+ /**
+ * Returns true if the token at {@code i} is the {@code module} contextual keyword introducing a module import
+ * declaration ({@code import module foo.bar;}, JEP 511), as opposed to an ordinary import whose first segment
+ * happens to be an identifier literally named {@code module} (for example {@code import module.Foo;}). As with
+ * other contextual keywords ({@code var}, {@code yield}, ...), this is disambiguated by lookahead: {@code
+ * module} only introduces a module import when the next token is another identifier, rather than {@code .} or
+ * {@code ;}. Whitespace, line terminators and comments are skipped, as they are by the parsing that follows.
+ */
+ private boolean isModuleKeyword(int i) {
+ if (!tokenAt(i).equals("module")) {
+ return false;
+ }
+ return isIdentifierToken(skipIgnored(i + 1, new Declaration()));
+ }
+
+ /**
+ * Skips whitespace, line terminators and comments starting at {@code i}, appending each comment to
+ * {@code declaration}, and returns the index of the first token that is none of those. Javadoc comments are not
+ * skipped: the formatter moves them onto a line of their own, which would separate the imports, so an import
+ * carrying one is rejected, as it was before module imports were supported.
+ */
+ private int skipIgnored(int i, Declaration declaration) {
+ while (i < toks.size()) {
+ if (isSpaceToken(i) || isNewlineToken(i)) {
+ i++;
+ } else if (isSlashSlashCommentToken(i) || isBlockCommentToken(i)) {
+ declaration.append(tokenAt(i).trim());
+ i++;
+ } else {
+ break;
+ }
+ }
+ return i;
+ }
+
private boolean isIdentifierToken(int i) {
String s = tokenAt(i);
return !s.isEmpty() && Character.isJavaIdentifierStart(s.codePointAt(0));
@@ -506,14 +619,23 @@ private boolean isSlashSlashCommentToken(int i) {
return toks.get(i).isSlashSlashComment();
}
+ /** True if the tok is a {@code /* *}{@code /} comment that is not javadoc. */
private boolean isBlockCommentToken(int i) {
- return toks.get(i).isComment() && !toks.get(i).isSlashSlashComment();
+ return toks.get(i).isSlashStarComment() && !toks.get(i).isJavadocComment();
}
+ /** True if the tok is a comment of any kind, javadoc included. */
private boolean isCommentToken(int i) {
return toks.get(i).isComment();
}
+ /** True if {@code text} ends in a line terminator. */
+ private static boolean endsInNewline(CharSequence text) {
+ return text.length() > 0
+ && Newlines.isNewline(
+ text.subSequence(text.length() - 1, text.length()).toString());
+ }
+
private boolean isNewlineToken(int i) {
return toks.get(i).isNewline();
}
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/JavaInputAstVisitor.java b/open-java-format/src/main/java/com/palantir/javaformat/java/JavaInputAstVisitor.java
index 3917911ee..0fd3569d8 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/JavaInputAstVisitor.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/JavaInputAstVisitor.java
@@ -132,6 +132,7 @@
import com.sun.tools.javac.code.Flags;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeScanner;
+import java.lang.reflect.Method;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
@@ -376,8 +377,15 @@ public Void visitCompilationUnit(CompilationUnitTree node, Void unused) {
builder.blankLineWanted(BlankLineWanted.YES);
}
markForPartialFormat();
- scan(type, null);
- builder.forcedBreak();
+ if (isCompactSourceFile(type)) {
+ // JEP 512 (Java 25): a "compact source file" has top-level fields/methods and no
+ // explicit class declaration. javac models this by synthesizing an implicit wrapper
+ // class; format its member list directly, at column zero, with no enclosing braces.
+ addBodyDeclarations(((ClassTree) type).getMembers(), BracesOrNot.NO, FirstDeclarationsOrNot.YES, ZERO);
+ } else {
+ scan(type, null);
+ builder.forcedBreak();
+ }
first = false;
dropEmptyDeclarations();
}
@@ -1123,12 +1131,19 @@ public Void visitIf(IfTree node, Void unused) {
return null;
}
+ // ImportTree#isModule() (JEP 511) exists from JDK 23 on; this module compiles with a JDK 21
+ // compiler, so it can't be referenced directly.
+ private static final Method IMPORT_TREE_IS_MODULE = maybeGetMethod(ImportTree.class, "isModule");
+
@Override
public Void visitImport(ImportTree node, Void unused) {
sync(node);
token("import");
builder.space();
- if (node.isStatic()) {
+ if (IMPORT_TREE_IS_MODULE != null && Boolean.TRUE.equals(invoke(IMPORT_TREE_IS_MODULE, node))) {
+ token("module");
+ builder.space();
+ } else if (node.isStatic()) {
token("static");
builder.space();
}
@@ -1381,6 +1396,16 @@ public Void visitAnnotatedType(AnnotatedTypeTree node, Void unused) {
protected static final long RECORD = 1L << 61;
+ // TODO: use Flags.IMPLICIT_CLASS once this module compiles with JDK 22 or later (JDK 21 calls
+ // bit 19 UNNAMED_CLASS). javac sets it on the class it synthesizes for a compact source file.
+ private static final long IMPLICIT_CLASS = 1L << 19;
+
+ /** Is {@code type} the implicit wrapper class javac synthesizes for a compact source file? */
+ private static boolean isCompactSourceFile(Tree type) {
+ return type instanceof JCTree.JCClassDecl
+ && (((JCTree.JCClassDecl) type).mods.flags & IMPLICIT_CLASS) == IMPLICIT_CLASS;
+ }
+
@SuppressWarnings("for-rollout:NullAway")
@Override
public Void visitMethod(MethodTree node, Void unused) {
@@ -3551,15 +3576,17 @@ int declareOne(
{
builder.open(ZERO);
{
- if (typeWithDims.isPresent() && typeWithDims.get().node != null) {
+ // `var` first: from JDK 27 (JDK-8268850) its type is a VarTypeTree rather than null,
+ // and this visitor, compiled against JDK 21, has no visitVarType to print it.
+ if (isVar) {
+ token("var");
+ } else if (typeWithDims.isPresent() && typeWithDims.get().node != null) {
scan(typeWithDims.get().node, null);
int totalDims = dims.size();
builder.open(plusFour);
maybeAddDims(dims);
builder.close();
baseDims = totalDims - dims.size();
- } else if (isVar) {
- token("var");
} else {
scan(type, null);
}
@@ -3741,9 +3768,22 @@ private void declareMany(List fragments, Direction annotationDirec
}
/** Add a list of declarations. */
- @SuppressWarnings("for-rollout:NullAway")
protected void addBodyDeclarations(
List extends Tree> bodyDeclarations, BracesOrNot braces, FirstDeclarationsOrNot first0) {
+ addBodyDeclarations(bodyDeclarations, braces, first0, plusTwo);
+ }
+
+ /**
+ * Add a list of declarations, indenting the member list by {@code memberIndent}. Used both for ordinary
+ * class/interface/enum bodies (indented by {@link #plusTwo}) and for the bare top-level member list of a
+ * compact source file (JEP 512), which is indented by {@link Indent.Const#ZERO}.
+ */
+ @SuppressWarnings("for-rollout:NullAway")
+ private void addBodyDeclarations(
+ List extends Tree> bodyDeclarations,
+ BracesOrNot braces,
+ FirstDeclarationsOrNot first0,
+ Indent memberIndent) {
if (bodyDeclarations.isEmpty()) {
if (braces.isYes()) {
builder.space();
@@ -3759,7 +3799,7 @@ protected void addBodyDeclarations(
tokenBreakTrailingComment("{", plusTwo);
builder.open(ZERO, BreakBehaviours.breakThisLevel(), LastLevelBreakability.ACCEPT_INLINE_CHAIN);
}
- builder.open(plusTwo);
+ builder.open(memberIndent);
boolean first = first0.isYes();
boolean lastOneGotBlankLineBefore = false;
PeekingIterator it = Iterators.peekingIterator(bodyDeclarations.iterator());
@@ -3958,6 +3998,29 @@ protected void sync(Tree node) {
builder.sync(((JCTree) node).getStartPosition());
}
+ /**
+ * Returns the public no-argument method {@code name} of {@code c}, or {@code null} if the running JDK does not
+ * have it. Used with {@link #invoke} to reach AST accessors that are newer than the compiler this module builds
+ * with, such as {@code ImportTree#isModule()} (JDK 23).
+ */
+ @SuppressWarnings("for-rollout:NullAway")
+ protected static Method maybeGetMethod(Class> c, String name) {
+ try {
+ return c.getMethod(name);
+ } catch (ReflectiveOperationException e) {
+ return null;
+ }
+ }
+
+ /** Invokes {@code m} on {@code target}, wrapping any {@link ReflectiveOperationException} in a runtime one. */
+ protected static Object invoke(Method m, Object target) {
+ try {
+ return m.invoke(target);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException(e.getMessage(), e);
+ }
+ }
+
@Override
public String toString() {
return MoreObjects.toStringHelper(this).add("builder", builder).toString();
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/RemoveUnusedImports.java b/open-java-format/src/main/java/com/palantir/javaformat/java/RemoveUnusedImports.java
index be322711f..1d2fae6d5 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/RemoveUnusedImports.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/RemoveUnusedImports.java
@@ -42,7 +42,6 @@
import com.sun.tools.javac.tree.JCTree.JCCompilationUnit;
import com.sun.tools.javac.tree.JCTree.JCFieldAccess;
import com.sun.tools.javac.tree.JCTree.JCIdent;
-import com.sun.tools.javac.tree.JCTree.JCImport;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.Options;
import java.lang.reflect.Method;
@@ -227,13 +226,16 @@ private static RangeMap buildReplacements(
Multimap> usedInJavadoc) {
RangeMap replacements = TreeRangeMap.create();
String sep = Newlines.guessLineSeparator(contents);
- for (JCImport importTree : unit.getImports()) {
+ // From JDK 23 on, getImports() also returns JCModuleImport, which is not a JCImport, so iterate
+ // over their common supertype and use ImportTree, which both implement.
+ for (JCTree importDecl : unit.getImports()) {
+ ImportTree importTree = (ImportTree) importDecl;
String simpleName = getSimpleName(importTree);
if (!isUnused(unit, usedNames, usedInJavadoc, importTree, simpleName)) {
continue;
}
// delete the import
- int endPosition = importTree.getEndPosition(unit.endPositions);
+ int endPosition = Trees.getEndPosition(importDecl, unit);
endPosition = Math.max(CharMatcher.isNot(' ').indexIn(contents, endPosition), endPosition);
if (endPosition + sep.length() < contents.length()
&& contents.subSequence(endPosition, endPosition + sep.length())
@@ -243,7 +245,7 @@ private static RangeMap buildReplacements(
}
// putCoalescing merges adjacent unused imports into one range, so the blank-line cleanup below sees the
// whole deleted import block (TreeRangeMap.put does not coalesce).
- replacements.putCoalescing(Range.closedOpen(importTree.getStartPosition(), endPosition), "");
+ replacements.putCoalescing(Range.closedOpen(importDecl.getStartPosition(), endPosition), "");
}
// Removing a whole import block can leave the blank line that preceded it stacked on the blank line that
// followed it (package, blank, imports, blank, type). Collapse one of them, so a single formatting pass leaves
@@ -293,6 +295,16 @@ private static boolean isBlankLineAfter(String contents, int pos, String sep) {
return pos + sep.length() <= contents.length() && contents.regionMatches(pos, sep, 0, sep.length());
}
+ // ImportTree#isModule() (JEP 511) exists from JDK 23 on; this module compiles with a JDK 21
+ // compiler, so it can't be referenced directly. Same idiom as CASE_TREE_GET_LABELS above.
+ private static final Method IMPORT_TREE_IS_MODULE =
+ JavaInputAstVisitor.maybeGetMethod(ImportTree.class, "isModule");
+
+ private static boolean isModuleImport(ImportTree importTree) {
+ return IMPORT_TREE_IS_MODULE != null
+ && Boolean.TRUE.equals(JavaInputAstVisitor.invoke(IMPORT_TREE_IS_MODULE, importTree));
+ }
+
private static String getSimpleName(ImportTree importTree) {
return importTree.getQualifiedIdentifier() instanceof JCIdent
? ((JCIdent) importTree.getQualifiedIdentifier()).getName().toString()
@@ -307,6 +319,11 @@ private static boolean isUnused(
Multimap> usedInJavadoc,
ImportTree importTree,
String simpleName) {
+ if (isModuleImport(importTree)) {
+ // A module import binds every exported package of the module, so this scanner can't tell
+ // whether it's needed - same as the `.*` wildcard imports below. Never remove it.
+ return false;
+ }
String qualifier = ((JCFieldAccess) importTree.getQualifiedIdentifier())
.getExpression()
.toString();
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/StringWrapper.java b/open-java-format/src/main/java/com/palantir/javaformat/java/StringWrapper.java
index fc9526862..80337879f 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/StringWrapper.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/StringWrapper.java
@@ -605,7 +605,7 @@ private static boolean noComments(String input, JCTree.JCCompilationUnit unit, T
CharMatcher.whitespace().or(CharMatcher.anyOf("\"+"));
private static int getEndPosition(JCTree.JCCompilationUnit unit, Tree tree) {
- return ((JCTree) tree).getEndPosition(unit.endPositions);
+ return Trees.getEndPosition(tree, unit);
}
private static int getStartPosition(Tree tree) {
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/Trees.java b/open-java-format/src/main/java/com/palantir/javaformat/java/Trees.java
index ead97b908..501561fc6 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/Trees.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/Trees.java
@@ -23,11 +23,19 @@
import com.sun.source.tree.ParenthesizedTree;
import com.sun.source.tree.Tree;
import com.sun.source.util.TreePath;
+import com.sun.tools.javac.parser.JavacParser;
+import com.sun.tools.javac.parser.ParserFactory;
import com.sun.tools.javac.tree.JCTree;
+import com.sun.tools.javac.tree.JCTree.JCCompilationUnit;
import com.sun.tools.javac.tree.Pretty;
import com.sun.tools.javac.tree.TreeInfo;
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.invoke.VarHandle;
+import javax.annotation.Nullable;
import javax.lang.model.element.Name;
/** Utilities for working with {@link Tree}s. */
@@ -44,8 +52,73 @@ static int getStartPosition(Tree expression) {
/** Returns the source end position of the node. */
static int getEndPosition(Tree expression, TreePath path) {
- return ((JCTree) expression)
- .getEndPosition(((JCTree.JCCompilationUnit) path.getCompilationUnit()).endPositions);
+ return getEndPosition(expression, (JCCompilationUnit) path.getCompilationUnit());
+ }
+
+ /** Returns the source end position of the node. */
+ static int getEndPosition(Tree tree, JCCompilationUnit unit) {
+ try {
+ return (int) GET_END_POSITION.invokeExact((JCTree) tree, unit);
+ } catch (RuntimeException | Error e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * Creates a parser that records end positions. JDK-8372948 (JDK 27) dropped the {@code keepEndPos} argument
+ * from {@link ParserFactory#newParser}, so the remaining four-argument overload now reads
+ * {@code (input, keepDocComments, keepLineMap, parseModuleInfo)}.
+ */
+ static JavacParser newParser(
+ ParserFactory parserFactory, CharSequence input, boolean keepDocComments, boolean keepLineMap) {
+ if (END_POS_TABLE_CLASS != null) {
+ return parserFactory.newParser(input, keepDocComments, /* keepEndPos= */ true, keepLineMap);
+ }
+ // The last argument is parseModuleInfo on these JDKs.
+ return parserFactory.newParser(input, keepDocComments, keepLineMap, false);
+ }
+
+ /**
+ * {@code com.sun.tools.javac.tree.EndPosTable}, or null on JDKs that store end positions directly in the tree
+ * (JDK-8372948, JDK 27 and later).
+ */
+ @Nullable
+ private static final Class> END_POS_TABLE_CLASS = endPosTableClass();
+
+ /** {@code (JCTree, JCCompilationUnit) -> int}, bound to whichever end position API this JDK has. */
+ private static final MethodHandle GET_END_POSITION = getEndPositionHandle();
+
+ @Nullable
+ private static Class> endPosTableClass() {
+ try {
+ return Class.forName("com.sun.tools.javac.tree.EndPosTable");
+ } catch (ClassNotFoundException e) {
+ return null;
+ }
+ }
+
+ private static MethodHandle getEndPositionHandle() {
+ MethodHandles.Lookup lookup = MethodHandles.lookup();
+ try {
+ if (END_POS_TABLE_CLASS == null) {
+ // (tree, unit) -> tree.getEndPosition()
+ return MethodHandles.dropArguments(
+ lookup.findVirtual(JCTree.class, "getEndPosition", MethodType.methodType(int.class)),
+ 1,
+ JCCompilationUnit.class);
+ }
+ // (tree, unit) -> tree.getEndPosition(unit.endPositions)
+ return MethodHandles.filterArguments(
+ lookup.findVirtual(
+ JCTree.class, "getEndPosition", MethodType.methodType(int.class, END_POS_TABLE_CLASS)),
+ 1,
+ lookup.findVarHandle(JCCompilationUnit.class, "endPositions", END_POS_TABLE_CLASS)
+ .toMethodHandle(VarHandle.AccessMode.GET));
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unsupported javac end position API", e);
+ }
}
/** Returns the source text for the node. */
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/java14/Java14InputAstVisitor.java b/open-java-format/src/main/java/com/palantir/javaformat/java/java14/Java14InputAstVisitor.java
index 76bb0d578..4566c0fbc 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/java14/Java14InputAstVisitor.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/java14/Java14InputAstVisitor.java
@@ -113,12 +113,20 @@ public Void visitBindingPattern(BindingPatternTree node, Void unused) {
return null;
}
+ /**
+ * Is {@code type} the {@code var} of a binding pattern? Older JDKs leave such a pattern without a type, JDK 27
+ * (JDK-8268850) gives it a VarTypeTree, which does not exist on JDK 21, so it is matched by {@link Tree.Kind} name.
+ */
+ private static boolean isVarType(Tree type) {
+ return type.getKind().name().equals("VAR_TYPE");
+ }
+
private void visitBindingPattern(ModifiersTree modifiers, Tree type, Name name) {
builder.open(plusFour);
if (modifiers != null) {
builder.addAll(visitModifiers(modifiers, Direction.HORIZONTAL, Optional.empty()));
}
- if (type == null) {
+ if (type == null || isVarType(type)) {
token("var");
} else {
scan(type, null);
@@ -351,23 +359,6 @@ public Void visitLambdaExpression(LambdaExpressionTree node, Void unused) {
return null;
}
- @SuppressWarnings("for-rollout:NullAway")
- private static Method maybeGetMethod(Class> c, String name) {
- try {
- return c.getMethod(name);
- } catch (ReflectiveOperationException e) {
- return null;
- }
- }
-
- private static Object invoke(Method m, Object target) {
- try {
- return m.invoke(target);
- } catch (ReflectiveOperationException e) {
- throw new RuntimeException(e.getMessage(), e);
- }
- }
-
@SuppressWarnings({"NullableProblems", "for-rollout:NullAway"})
protected ExpressionTree getGuard(final CaseTree node) {
return null;
diff --git a/open-java-format/src/main/java/com/palantir/javaformat/java/java21/Java21InputAstVisitor.java b/open-java-format/src/main/java/com/palantir/javaformat/java/java21/Java21InputAstVisitor.java
index 5f089c4e6..f6f586ee7 100644
--- a/open-java-format/src/main/java/com/palantir/javaformat/java/java21/Java21InputAstVisitor.java
+++ b/open-java-format/src/main/java/com/palantir/javaformat/java/java21/Java21InputAstVisitor.java
@@ -25,6 +25,7 @@
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.PatternCaseLabelTree;
import com.sun.source.tree.PatternTree;
+import com.sun.source.tree.Tree;
import javax.lang.model.element.Name;
/**
@@ -33,10 +34,25 @@
*/
@SuppressWarnings("Since21")
public class Java21InputAstVisitor extends Java14InputAstVisitor {
+ // AnyPatternTree (the unnamed pattern `_`, JEP 456) is still a preview API on JDK 21, which this
+ // module compiles with, so match it by Tree.Kind name instead of by type.
+ private static final String ANY_PATTERN_KIND_NAME = "ANY_PATTERN";
+
public Java21InputAstVisitor(OpsBuilder builder, int indentMultiplier) {
super(builder, indentMultiplier);
}
+ @Override
+ public Void scan(Tree tree, Void unused) {
+ if (tree != null && tree.getKind().name().equals(ANY_PATTERN_KIND_NAME)) {
+ // No sync(tree): javac records the start position one past the `_`, which makes sync()
+ // think a token was skipped and throw. This branch emits one leaf token and never recurses.
+ token("_");
+ return null;
+ }
+ return super.scan(tree, null);
+ }
+
@Override
protected ExpressionTree getGuard(final CaseTree node) {
return node.getGuard();
diff --git a/open-java-format/src/test/java/com/palantir/javaformat/java/AospImportStyleTest.java b/open-java-format/src/test/java/com/palantir/javaformat/java/AospImportStyleTest.java
index 9401b1299..f568f8d9e 100644
--- a/open-java-format/src/test/java/com/palantir/javaformat/java/AospImportStyleTest.java
+++ b/open-java-format/src/test/java/com/palantir/javaformat/java/AospImportStyleTest.java
@@ -277,6 +277,79 @@ public static List