Skip to content

Replace Guava's immutable collections with java.util and ban them outside the SPI - #218

Merged
abashev merged 6 commits into
21.X.Xfrom
abashev/replace-guava-immutablelist-immutablemap-immutab
Nov 12, 2026
Merged

abashev merged 6 commits into
21.X.Xfrom
abashev/replace-guava-immutablelist-immutablemap-immutab

Conversation

@abashev

@abashev abashev commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Before this PR

The code used Guava's ImmutableList, ImmutableMap and ImmutableSet throughout, although CLAUDE.md asks new code for plain java.util collections, and nothing stopped them from coming back. #202 has the plan and the pitfalls found on the way.

After this PR

==COMMIT_MSG==
Guava's ImmutableList, ImmutableMap and ImmutableSet give way to java.util collections, and forbidden-apis fails the build on them in every module but the SPI, whose FormatterService keeps returning ImmutableList for compatibility.
==COMMIT_MSG==

Closes #202.

  • The migration (first commit): List.of, List.copyOf, Map.of, Stream.toList(), and an ArrayList or TreeSet where a builder was filled. Three places depended on how Guava's collections behave: Newlines.BREAKS is a List, since it is tried in order and Set.of keeps none; JavaInput.buildToks keeps its stop tokens in a HashSet, since it asks them about a null token kind; @Immutable is gone from five classes, since Error Prone cannot prove a java.util List or Map immutable. FileBasedTests lists its resources with Files.walk instead of Guava's ClassPath.
  • The ban (second commit): forbidden-apis 3.11, the convention open-java-format.forbidden-apis-conventions, the signatures in config/forbidden-apis/guava-immutables.txt. It works on bytecode, so it also catches a var, a subclass such as ImmutableSortedSet, and a call on what a Guava method returns.
  • The SPI is left out of the check and unchanged: FormatterService.getFormatReplacements returns ImmutableList, so that code compiled against an older SPI keeps linking. Its three implementations, FormatterServiceImpl, BootstrappingFormatterService and NativeImageFormatterService, have to declare it too; those methods carry a package-private @SuppressForbidden(reason = …) of their module, and the rest of each class is checked. Code that calls the method and keeps the result as a List passes the check.
  • CI (third commit): the build job runs ./gradlew check rather than ./gradlew test, since forbidden-apis hangs off check. That adds Checkstyle and validatePlugins, which pass. The jdk jobs still run only the tests.
  • Error Prone's DefaultCharset is an error (last commit). Its two findings were in FormatDiff, which decoded what a failed git command printed in the platform's charset, while a successful one's output is read as UTF-8; both are UTF-8 now.

How it was checked

  • ./gradlew check: 1422 tests in open-java-format (14 skipped, as on 21.X.X), 141 in the Gradle plugin, 65 in the IntelliJ plugin, then Checkstyle, forbidden-apis and validatePlugins.
  • As the native job does, on GraalVM CE 25.0.2: -PnativeImage=true :open-java-format-native:nativeCompile, then :open-java-format-jdk-bootstrap:test :gradle-open-java-format:test against the image, 8 and 152 tests.
  • ImmutableList.copyOf put into NativeImageFormatterService's toArgs() fails forbiddenApisMain (tried, then reverted).

Possible downsides?

  • Formatter.getFormatReplacements, Input.getPositionTokenMap and SnippetFormatter.format are public and now return List and RangeMap, so code compiled against the old ImmutableList and ImmutableRangeMap signatures would no longer link. Spotless's glue calls none of them (Replace Guava ImmutableList/ImmutableMap/ImmutableSet with java.util and ban them with forbidden-apis #202 lists what it links against).
  • List.of and its kin throw on null elements and on contains(null), and Set.of and Map.of keep no order. The places the tests showed are fixed; a path no test reaches could still hit one of these.
  • CheckForbiddenApis is not cacheable. It is up to date when nothing changed and takes some 0.03 s a module when it runs.
  • A Checkstyle violation that CI used to let through now fails the build job.

ImmutableList, ImmutableMap, ImmutableSet and ImmutableSortedSet give
way to List.of, List.copyOf, Map.of, Stream.toList() and, where a
builder filled them, an ArrayList or a TreeSet handed out with
List.copyOf or an unmodifiable view. FileBasedTests lists its test
resources with Files.walk rather than Guava's ClassPath, whose
getResources() returns an ImmutableSet.

What differs between the two kinds of collection decided three places:
- Newlines.BREAKS is a List: it is tried in order, "\r\n" before "\n",
  and Set.of keeps no order. StringWrapper's ranges are a LinkedHashSet
  for the same reason.
- JavaInput.buildToks asks its stop tokens whether they contain the
  kind of a comment token, which is null, and Set.of throws on
  contains(null). They are a HashSet.
- Error Prone's @immutable cannot prove a java.util List or Map
  immutable, so it is gone from Input.Token, JavaInput.Token,
  OpsBuilder.BlankLineWanted, InputMetadata and doc.Token.

The SPI does not change. FormatterService.getFormatReplacements still
returns ImmutableList, so that code compiled against an older SPI keeps
linking, and its three implementations still declare it:
FormatterServiceImpl copies Formatter's list into one. Formatter's own
getFormatReplacements, Input.getPositionTokenMap (a RangeMap now) and
SnippetFormatter.format return java.util types.
forbidden-apis 3.11 checks the bytecode of every module but the SPI
against config/forbidden-apis/guava-immutables.txt, which names
ImmutableList, ImmutableMap and ImmutableSet, and fails the build on a
use of one. Error Prone has no check that bans a class, and Picnic's
error-prone-support pushes towards Guava's immutables rather than away.
The check sees what a source-level ban would miss: a var whose type is
one of them, a subclass such as ImmutableSortedSet, and a call on what
a Guava method like ClassPath.getResources() returns.

The SPI is left out because FormatterService.getFormatReplacements
returns ImmutableList for compatibility. The methods that implement it
in open-java-format and open-java-format-jdk-bootstrap carry
@SuppressForbidden, an annotation of each module's own: the default
one would put forbidden-apis on their compile classpath. Code that
calls the method and keeps the result as a List passes the check.

ignoreSignaturesOfMissingClasses, because the benchmarks have no Guava
on their classpath and would otherwise fail to parse the signatures.
The build job ran ./gradlew test, and forbidden-apis hangs off check,
so the previous commit's ban would never have failed a pull request.
check also runs Checkstyle and the Gradle plugin's validatePlugins,
which pass. The jdk jobs, which run the same code on other JDKs, keep
running only the tests. The README and CLAUDE.md say what CI runs.
Collectors.toUnmodifiableList() was there only because the list was
declared List<Tree>, and Stream.toList() on getMembers(), a
List<? extends Tree>, gives a list of the wildcard's capture.
addBodyDeclarations takes a List<? extends Tree>, so the variable is a
var and the stream ends in toList(), as CLAUDE.md asks.
FormatDiff decoded what a failed git command printed to stdout and
stderr in the platform's charset, while the output of one that
succeeds, three lines further down, is read as UTF-8. Both are UTF-8
now.
The block comment above JavaInput's fields described "the following
lists" of tok indices on each line, which live in InputOutput.ranges,
and Error Prone's AlmostJavadoc flagged its {@code} tags. What it said
that the field's Javadoc did not, that a /* comment spans lines and
that the lists give the correspondence between input and output, is
in that Javadoc now.
@abashev
abashev merged commit 1b71438 into 21.X.X Nov 12, 2026
16 of 17 checks passed
@abashev
abashev deleted the abashev/replace-guava-immutablelist-immutablemap-immutab branch November 12, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace Guava ImmutableList/ImmutableMap/ImmutableSet with java.util and ban them with forbidden-apis

1 participant