diff --git a/CHANGES.md b/CHANGES.md
index 1840598f80..fcd3982bbc 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -10,12 +10,14 @@ This document is intended for Spotless developers.
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`).
## [Unreleased]
+### Added
+- New `ShortenFullyQualifiedTypesStep` for Java, which replaces fully-qualified type names with their simple names and adds the imports they need. Uses JavaParser to find type references in the AST, so occurrences in strings, comments, and other non-type contexts are left alone. ([#2945](https://github.com/diffplug/spotless/issues/2945))
+- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
### Fixed
- `removeUnusedImports` no longer throws on Java `import module` declarations (`JCModuleImport` ClassCastException). ([#2890](https://github.com/diffplug/spotless/issues/2890))
- Concurrent P2 provisioning (parallel multi-project Gradle fingerprinting of `eclipse()` / `greclipse()` steps) no longer races Solstice's on-disk cache; also `ConfigurationCacheHackList.toString()` no longer evaluates step state (which could re-trigger provisioning while Gradle reports "cannot be serialized"). ([#3004](https://github.com/diffplug/spotless/issues/3004))
### Changes
- Default `google-java-format` remains `1.28.0` on JVM 17; bumps to `1.30.0` on JVM 21+; require at least `1.30.0` on JVM 25+ for `import module` support.
-- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `eclipse` version to latest `4.39` -> `4.40`. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `adocfmt` version `0.2.0` -> `0.3.1`, which adds table formatting support (`formatTables`, `tableLayout`, `tableMaxLineWidth`, `tableBlankLines`).
diff --git a/README.md b/README.md
index ebe8e95788..921c50d2c5 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ lib('java.RemoveUnusedImportsStep') +'{{yes}} | {{yes}}
lib('java.ExpandWildcardImportsStep') +'{{yes}} | {{no}} | {{no}} | {{no}} |',
lib('java.ForbidWildcardImportsStep') +'{{yes}} | {{yes}} | {{yes}} | {{no}} |',
lib('java.ForbidModuleImportsStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |',
+lib('java.ShortenFullyQualifiedTypesStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |',
extra('java.EclipseJdtFormatterStep') +'{{yes}} | {{yes}} | {{yes}} | {{no}} |',
lib('java.FormatAnnotationsStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |',
lib('java.CleanthatJavaStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |',
@@ -149,6 +150,7 @@ lib('yaml.JacksonYamlStep') +'{{yes}} | {{yes}}
| [`java.ExpandWildcardImportsStep`](lib/src/main/java/com/diffplug/spotless/java/ExpandWildcardImportsStep.java) | :+1: | :white_large_square: | :white_large_square: | :white_large_square: |
| [`java.ForbidWildcardImportsStep`](lib/src/main/java/com/diffplug/spotless/java/ForbidWildcardImportsStep.java) | :+1: | :+1: | :+1: | :white_large_square: |
| [`java.ForbidModuleImportsStep`](lib/src/main/java/com/diffplug/spotless/java/ForbidModuleImportsStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: |
+| [`java.ShortenFullyQualifiedTypesStep`](lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: |
| [`java.EclipseJdtFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/java/EclipseJdtFormatterStep.java) | :+1: | :+1: | :+1: | :white_large_square: |
| [`java.FormatAnnotationsStep`](lib/src/main/java/com/diffplug/spotless/java/FormatAnnotationsStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: |
| [`java.CleanthatJavaStep`](lib/src/main/java/com/diffplug/spotless/java/CleanthatJavaStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: |
diff --git a/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java b/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java
new file mode 100644
index 0000000000..510c205d81
--- /dev/null
+++ b/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2025-2026 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.glue.javaparser;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.github.javaparser.JavaParser;
+import com.github.javaparser.ParseResult;
+import com.github.javaparser.ParserConfiguration;
+import com.github.javaparser.Position;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.ImportDeclaration;
+import com.github.javaparser.ast.PackageDeclaration;
+import com.github.javaparser.ast.type.ClassOrInterfaceType;
+import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
+
+import com.diffplug.spotless.FormatterFunc;
+
+/**
+ * Uses JavaParser to identify fully qualified type references in the AST,
+ * then performs text-level replacement to shorten them and add imports.
+ *
+ *
The parser gives us accurate type-context identification (no false positives
+ * from strings, comments, or non-type contexts). Text-level replacement preserves
+ * the original formatting exactly.
+ */
+public class ShortenQualifiedTypesFormatterFunc implements FormatterFunc {
+
+ private final JavaParser parser = new JavaParser(
+ new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE));
+
+ @Override
+ public String apply(String rawUnix) throws Exception {
+ ParseResult parseResult = parser.parse(rawUnix);
+ if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
+ return rawUnix;
+ }
+ CompilationUnit cu = parseResult.getResult().get();
+
+ // 1. Collect the package name
+ String packageName = cu.getPackageDeclaration()
+ .map(PackageDeclaration::getNameAsString)
+ .orElse("");
+
+ // 2. Collect existing non-static imports
+ Map existingImportsBySimple = new LinkedHashMap<>();
+ Set existingImportFqns = new LinkedHashSet<>();
+ for (ImportDeclaration imp : cu.getImports()) {
+ if (imp.isStatic() || imp.isAsterisk()) {
+ continue;
+ }
+ String fqn = imp.getNameAsString();
+ existingImportFqns.add(fqn);
+ String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
+ existingImportsBySimple.put(simple, fqn);
+ }
+
+ // 3. Walk the AST to find outermost fully-qualified type nodes
+ Map> simpleToFqns = new LinkedHashMap<>();
+ List qualifiedRefs = new ArrayList<>();
+
+ cu.accept(new CollectQualifiedTypesVisitor(simpleToFqns, qualifiedRefs), null);
+
+ if (qualifiedRefs.isEmpty()) {
+ return rawUnix;
+ }
+
+ // 4. Determine which FQNs are safe to shorten
+ Set safeToShorten = new LinkedHashSet<>();
+ for (Map.Entry> entry : simpleToFqns.entrySet()) {
+ String simple = entry.getKey();
+ Set fqns = entry.getValue();
+ if (fqns.size() > 1) {
+ continue;
+ }
+ String fqn = fqns.iterator().next();
+ String existing = existingImportsBySimple.get(simple);
+ if (existing != null && !existing.equals(fqn)) {
+ continue;
+ }
+ safeToShorten.add(fqn);
+ }
+
+ if (safeToShorten.isEmpty()) {
+ return rawUnix;
+ }
+
+ // 5. Convert line/column positions to string offsets and replace
+ // Build line-start offset table
+ int[] lineOffsets = buildLineOffsets(rawUnix);
+
+ // Use a set keyed on start offset to deduplicate (JavaParser may visit the same node twice,
+ // e.g. for instanceof pattern variables)
+ Map removalsByStart = new LinkedHashMap<>();
+ for (QualifiedTypeRef ref : qualifiedRefs) {
+ if (!safeToShorten.contains(ref.fqn)) {
+ continue;
+ }
+ int scopeStartOffset = toOffset(lineOffsets, ref.scopeStart);
+ int nameStartOffset = toOffset(lineOffsets, ref.nameStart);
+ if (scopeStartOffset >= 0 && nameStartOffset > scopeStartOffset) {
+ removalsByStart.putIfAbsent(scopeStartOffset, new int[]{scopeStartOffset, nameStartOffset});
+ }
+ }
+ List removals = new ArrayList<>(removalsByStart.values());
+
+ // Sort removals in reverse order so we can apply them without invalidating offsets
+ removals.sort(Comparator.comparingInt((int[] a) -> a[0]).reversed());
+
+ StringBuilder sb = new StringBuilder(rawUnix);
+ for (int[] removal : removals) {
+ sb.delete(removal[0], removal[1]);
+ }
+
+ // 6. Add missing imports
+ Set newImports = new TreeSet<>();
+ for (String fqn : safeToShorten) {
+ if (fqn.startsWith("java.lang.") && fqn.indexOf('.', 10) == -1) {
+ continue;
+ }
+ if (!packageName.isEmpty() && fqn.startsWith(packageName + ".")
+ && fqn.indexOf('.', packageName.length() + 1) == -1) {
+ continue;
+ }
+ if (existingImportFqns.contains(fqn)) {
+ continue;
+ }
+ newImports.add(fqn);
+ }
+
+ if (!newImports.isEmpty()) {
+ String result = sb.toString();
+ int insertPos = findImportInsertPosition(result);
+ boolean afterExistingImport = IMPORT_LINE.matcher(result).find();
+
+ StringBuilder importBlock = new StringBuilder();
+ if (!afterExistingImport) {
+ importBlock.append('\n');
+ }
+ for (String fqn : newImports) {
+ importBlock.append("\nimport ").append(fqn).append(';');
+ }
+ sb = new StringBuilder(result);
+ sb.insert(insertPos, importBlock);
+ }
+
+ return sb.toString();
+ }
+
+ private record QualifiedTypeRef(String fqn, String simpleName, Position scopeStart, Position nameStart) {}
+
+ /** Collects the outermost fully-qualified type nodes, along with the text range of the scope to remove. */
+ private static final class CollectQualifiedTypesVisitor extends VoidVisitorAdapter {
+ private final Map> simpleToFqns;
+ private final List qualifiedRefs;
+
+ CollectQualifiedTypesVisitor(Map> simpleToFqns, List qualifiedRefs) {
+ this.simpleToFqns = simpleToFqns;
+ this.qualifiedRefs = qualifiedRefs;
+ }
+
+ @Override
+ public void visit(ClassOrInterfaceType type, Void arg) {
+ super.visit(type, arg);
+ if (type.getScope().isEmpty()) {
+ return;
+ }
+ // Skip types that are themselves the scope of a parent type
+ if (type.getParentNode().isPresent()
+ && type.getParentNode().get() instanceof ClassOrInterfaceType parent
+ && parent.getScope().isPresent()
+ && parent.getScope().get() == type) {
+ return;
+ }
+ String rawName = buildRawName(type);
+ if (!startsWithPackage(rawName)) {
+ return;
+ }
+ String simple = type.getNameAsString();
+ simpleToFqns.computeIfAbsent(simple, k -> new LinkedHashSet<>()).add(rawName);
+
+ // Record the text range of the scope (to be removed)
+ ClassOrInterfaceType scope = type.getScope().get();
+ if (scope.getBegin().isPresent() && type.getName().getBegin().isPresent()) {
+ Position scopeStart = scope.getBegin().get();
+ Position nameStart = type.getName().getBegin().get();
+ qualifiedRefs.add(new QualifiedTypeRef(rawName, simple, scopeStart, nameStart));
+ }
+ }
+ }
+
+ private static String buildRawName(ClassOrInterfaceType type) {
+ StringBuilder sb = new StringBuilder();
+ buildRawNameRecursive(type, sb);
+ return sb.toString();
+ }
+
+ private static void buildRawNameRecursive(ClassOrInterfaceType type, StringBuilder sb) {
+ if (type.getScope().isPresent()) {
+ buildRawNameRecursive(type.getScope().get(), sb);
+ sb.append('.');
+ }
+ sb.append(type.getNameAsString());
+ }
+
+ private static boolean startsWithPackage(String rawName) {
+ return !rawName.isEmpty() && Character.isLowerCase(rawName.charAt(0));
+ }
+
+ /** Builds an array where lineOffsets[line] is the char offset of the start of that line (1-indexed). */
+ private static int[] buildLineOffsets(String text) {
+ List offsets = new ArrayList<>();
+ offsets.add(0); // dummy for 0-index
+ offsets.add(0); // line 1 starts at offset 0
+ for (int i = 0; i < text.length(); i++) {
+ if (text.charAt(i) == '\n') {
+ offsets.add(i + 1);
+ }
+ }
+ return offsets.stream().mapToInt(Integer::intValue).toArray();
+ }
+
+ /** Converts a JavaParser Position (1-indexed line/column) to a string offset. */
+ private static int toOffset(int[] lineOffsets, Position pos) {
+ if (pos.line < 1 || pos.line >= lineOffsets.length) {
+ return -1;
+ }
+ return lineOffsets[pos.line] + pos.column - 1; // column is 1-indexed
+ }
+
+ private static final Pattern IMPORT_LINE = Pattern.compile("^[ \\t]*import\\s+[\\w.]+\\s*;", Pattern.MULTILINE);
+ private static final Pattern PACKAGE_LINE = Pattern.compile("^\\s*package\\s+[\\w.]+\\s*;", Pattern.MULTILINE);
+
+ /** Finds the best position to insert new import statements. */
+ private static int findImportInsertPosition(String text) {
+ Matcher m = IMPORT_LINE.matcher(text);
+ int lastImportEnd = -1;
+ while (m.find()) {
+ lastImportEnd = m.end();
+ }
+ if (lastImportEnd >= 0) {
+ return lastImportEnd;
+ }
+ Matcher pkg = PACKAGE_LINE.matcher(text);
+ if (pkg.find()) {
+ return pkg.end();
+ }
+ return 0;
+ }
+}
diff --git a/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java b/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java
new file mode 100644
index 0000000000..a24e200b66
--- /dev/null
+++ b/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2025-2026 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.java;
+
+import static com.diffplug.spotless.JarState.from;
+import static com.diffplug.spotless.JarState.promise;
+import static java.util.Objects.requireNonNull;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.lang.reflect.InvocationTargetException;
+
+import com.diffplug.spotless.FormatterFunc;
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.JarState;
+import com.diffplug.spotless.Provisioner;
+
+/**
+ * Replaces fully qualified type names with simple names and adds the necessary imports.
+ * Uses JavaParser to identify type references in the AST, avoiding false positives
+ * in strings, comments, annotations, and other non-type contexts.
+ *
+ * Designed to run before {@code importOrder()} and {@code removeUnusedImports()}.
+ */
+public final class ShortenFullyQualifiedTypesStep implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private static final String NAME = "shortenFullyQualifiedTypes";
+ private static final String INCOMPATIBLE_ERROR_MESSAGE = "There was a problem interacting with JavaParser; maybe you set an incompatible version?";
+ private static final String MAVEN_COORDINATES = "com.github.javaparser:javaparser-core:3.27.1";
+
+ private final JarState.Promised jarState;
+
+ private ShortenFullyQualifiedTypesStep(JarState.Promised jarState) {
+ this.jarState = jarState;
+ }
+
+ public static FormatterStep create(Provisioner provisioner) {
+ requireNonNull(provisioner);
+ return FormatterStep.create(NAME,
+ new ShortenFullyQualifiedTypesStep(promise(() -> from(MAVEN_COORDINATES, provisioner))),
+ ShortenFullyQualifiedTypesStep::equalityState,
+ State::toFormatter);
+ }
+
+ private State equalityState() {
+ return new State(jarState.get());
+ }
+
+ private record State(JarState jarState) implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ FormatterFunc toFormatter() {
+ try {
+ return (FormatterFunc) jarState
+ .getClassLoader()
+ .loadClass("com.diffplug.spotless.glue.javaparser.ShortenQualifiedTypesFormatterFunc")
+ .getConstructor()
+ .newInstance();
+ } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException
+ | InstantiationException | IllegalAccessException | NoClassDefFoundError cause) {
+ throw new IllegalStateException(INCOMPATIBLE_ERROR_MESSAGE, cause);
+ }
+ }
+}}
diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md
index bc43b6c43a..b065710655 100644
--- a/plugin-gradle/CHANGES.md
+++ b/plugin-gradle/CHANGES.md
@@ -3,6 +3,9 @@
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`).
## [Unreleased]
+### Added
+- New `shortenFullyQualifiedTypes()` step for Java, which replaces fully-qualified type names with their simple names and adds the imports they need. Best combined with `importOrder()` and `removeUnusedImports()`. ([#2945](https://github.com/diffplug/spotless/issues/2945))
+- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
### Fixed
- `removeUnusedImports` no longer fails on Java `import module` declarations. ([#2890](https://github.com/diffplug/spotless/issues/2890))
- `expandWildcardImports()` now builds its type-solver classpath from each Java source set's compile classpath instead of every resolvable configuration. Unrelated configurations (for example generated-code or custom resolvable configs that are not ready yet) are no longer resolved. ([#2998](https://github.com/diffplug/spotless/issues/2998))
@@ -10,7 +13,6 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
- Parallel multi-project builds no longer intermittently fail with "Cannot fingerprint input property 'stepsInternalEquality': ConfigurationCacheHackList cannot be serialized" / "Failed to provision P2 dependencies" when using `eclipse()` (or other P2-backed steps). Subprojects now share one deduping P2 provisioner and P2 queries are serialized process-wide. ([#3004](https://github.com/diffplug/spotless/issues/3004))
### Changes
- Default `google-java-format` remains `1.28.0` on JVM 17; bumps to `1.30.0` on JVM 21+; require at least `1.30.0` on JVM 25+ for `import module` support.
-- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `eclipse` version to latest `4.39` -> `4.40`. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `adocfmt` version `0.2.0` -> `0.3.1`, which adds table formatting support (`formatTables`, `tableLayout`, `tableMaxLineWidth`, `tableBlankLines`).
diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md
index 15c0f91817..29b3a8120a 100644
--- a/plugin-gradle/README.md
+++ b/plugin-gradle/README.md
@@ -56,7 +56,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui
- [Git hook (optional)](#git-hook)
- [Linting](#linting)
- **Languages**
- - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [prince-of-space](#prince-of-space), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [tabletest-formatter](#tabletest-formatter), [IntelliJ IDEA](#intellij-idea))
+ - [Java](#java) ([removeUnusedImports](#removeunusedimports), [forbidWildcardImports](#forbidwildcardimports), [expandWildcardImports](#expandwildcardimports), [forbidModuleImports](#forbidmoduleimports), [shortenFullyQualifiedTypes](#shortenfullyqualifiedtypes), [google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [clang-format](#clang-format), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [prince-of-space](#prince-of-space), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [tabletest-formatter](#tabletest-formatter), [IntelliJ IDEA](#intellij-idea))
- [Groovy](#groovy) ([eclipse groovy](#eclipse-groovy))
- [Kotlin](#kotlin) ([ktfmt](#ktfmt), [ktlint](#ktlint), [diktat](#diktat), [tabletest-formatter](#tabletest-formatter-1), [prettier](#prettier))
- [Scala](#scala) ([scalafmt](#scalafmt))
@@ -210,6 +210,7 @@ spotless {
removeUnusedImports()
forbidWildcardImports() // or expandWildcardImports, see below
forbidModuleImports()
+ shortenFullyQualifiedTypes() // replaces fully-qualified type names with simple names + imports
// Cleanthat will refactor your code, but it may break your style: apply it before your formatter
cleanthat() // has its own section below
@@ -286,6 +287,67 @@ spotless {
}
```
+### shortenFullyQualifiedTypes
+
+Replaces fully-qualified type names with their simple names, adding the imports they need. Useful for cleaning up generated code, or code where inline fully-qualified names have crept in.
+
+[JavaParser](https://javaparser.org/) parses the source and only type positions in the AST are rewritten, so fully-qualified names appearing in strings, text blocks, and comments are left alone. Unlike [`expandWildcardImports`](#expandwildcardimports), it works from the source file alone and does not need your compile classpath.
+
+New imports are appended after the existing ones, so run this before `importOrder()`:
+
+```gradle
+spotless {
+ java {
+ shortenFullyQualifiedTypes()
+ importOrder()
+ removeUnusedImports()
+ }
+}
+```
+
+Before:
+
+```java
+package com.acme;
+
+public class UserService {
+ private final java.util.Map> cache = new java.util.HashMap<>();
+
+ public java.util.List getUsers(java.util.function.Predicate filter) throws java.io.IOException {
+ return new java.util.ArrayList<>();
+ }
+}
+```
+
+After:
+
+```java
+package com.acme;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+public class UserService {
+ private final Map> cache = new HashMap<>();
+
+ public List getUsers(Predicate filter) throws IOException {
+ return new ArrayList<>();
+ }
+}
+```
+
+A name is left fully-qualified whenever shortening it could change what the code means:
+
+- two different fully-qualified names in the file would collapse to the same simple name (e.g. `java.util.List` and `java.awt.List`)
+- an existing import already binds that simple name to a different type
+- the file does not parse
+
+Types in `java.lang` and in the file's own package are shortened without adding an import.
+
### google-java-format
[homepage](https://github.com/google/google-java-format). [changelog](https://github.com/google/google-java-format/releases).
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
index ea93fb23ca..9411811322 100644
--- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java
@@ -47,6 +47,7 @@
import com.diffplug.spotless.java.PalantirJavaFormatStep;
import com.diffplug.spotless.java.PrinceOfSpaceStep;
import com.diffplug.spotless.java.RemoveUnusedImportsStep;
+import com.diffplug.spotless.java.ShortenFullyQualifiedTypesStep;
import com.diffplug.spotless.java.TableTestFormatterStep;
public class JavaExtension extends FormatExtension implements HasBuiltinDelimiterForLicense, JvmLang {
@@ -168,6 +169,11 @@ public void forbidWildcardImports() {
addStep(ForbidWildcardImportsStep.create());
}
+ /** Shortens fully qualified type names and adds imports. */
+ public void shortenFullyQualifiedTypes() {
+ addStep(ShortenFullyQualifiedTypesStep.create(provisioner()));
+ }
+
public void forbidModuleImports() {
addStep(ForbidModuleImportsStep.create());
}
diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md
index 549c6b6232..df3dab9695 100644
--- a/plugin-maven/CHANGES.md
+++ b/plugin-maven/CHANGES.md
@@ -3,17 +3,19 @@
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`).
## [Unreleased]
+### Added
+- New `` step for Java, which replaces fully-qualified type names with their simple names and adds the imports they need. Best combined with `` and ``. ([#2945](https://github.com/diffplug/spotless/issues/2945))
+- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
+- Add support to apply alternate license header within same format ([#872](https://github.com/diffplug/spotless/issues/872))
+- Add support to skip license header application based on source file content pattern ([#650](https://github.com/diffplug/spotless/issues/650)).
### Fixed
- `removeUnusedImports` no longer fails on Java `import module` declarations. ([#2890](https://github.com/diffplug/spotless/issues/2890))
- Concurrent P2 provisioning no longer races Solstice's on-disk cache (affects Eclipse-based formatters under parallel builds). ([#3004](https://github.com/diffplug/spotless/issues/3004))
### Changes
- Default `google-java-format` remains `1.28.0` on JVM 17; bumps to `1.30.0` on JVM 21+; require at least `1.30.0` on JVM 25+ for `import module` support.
-- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `eclipse` version to latest `4.39` -> `4.40`. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Document Maven skip properties `spotless.skip`, `spotless.check.skip`, and `spotless.apply.skip`. Goal-specific skips now live on their own mojos so they no longer leak across goals. ([#3009](https://github.com/diffplug/spotless/pull/3009))
- Bump default `adocfmt` version `0.2.0` -> `0.3.1`, which adds table formatting support (``, ``, ``, ``).
-- Add support to apply alternate license header within same format ([#872](https://github.com/diffplug/spotless/issues/872))
-- Add support to skip license header application based on source file content pattern ([#650](https://github.com/diffplug/spotless/issues/650)).
## [3.9.0] - 2026-07-27
### Added
diff --git a/plugin-maven/README.md b/plugin-maven/README.md
index 5479b01adf..42492e78f9 100644
--- a/plugin-maven/README.md
+++ b/plugin-maven/README.md
@@ -40,7 +40,7 @@ user@machine repo % mvn spotless:check
- [Git hook (optional)](#git-hook)
- [Binding to maven phase](#binding-to-maven-phase)
- **Languages**
- - [Java](#java) ([google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [prince-of-space](#prince-of-space), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [tabletest-formatter](#tabletest-formatter), [IntelliJ IDEA](#intellij-idea))
+ - [Java](#java) ([removeUnusedImports](#removeunusedimports), [forbidWildcardImports](#forbidwildcardimports), [expandWildcardImports](#expandwildcardimports), [forbidModuleImports](#forbidmoduleimports), [shortenFullyQualifiedTypes](#shortenfullyqualifiedtypes), [google-java-format](#google-java-format), [eclipse jdt](#eclipse-jdt), [prettier](#prettier), [palantir-java-format](#palantir-java-format), [prince-of-space](#prince-of-space), [formatAnnotations](#formatAnnotations), [cleanthat](#cleanthat), [tabletest-formatter](#tabletest-formatter), [IntelliJ IDEA](#intellij-idea))
- [Groovy](#groovy) ([eclipse groovy](#eclipse-groovy))
- [Kotlin](#kotlin) ([ktfmt](#ktfmt), [ktlint](#ktlint), [diktat](#diktat), [tabletest-formatter](#tabletest-formatter-1), [prettier](#prettier))
- [Scala](#scala) ([scalafmt](#scalafmt))
@@ -236,6 +236,7 @@ any other maven phase (i.e. compile) then it can be configured as below;
+
@@ -276,6 +277,63 @@ This operation can be resource intensive when formatting many source files, so y
```
+### shortenFullyQualifiedTypes
+
+Replaces fully-qualified type names with their simple names, adding the imports they need. Useful for cleaning up generated code, or code where inline fully-qualified names have crept in.
+
+[JavaParser](https://javaparser.org/) parses the source and only type positions in the AST are rewritten, so fully-qualified names appearing in strings, text blocks, and comments are left alone. Unlike [`expandWildcardImports`](#expandwildcardimports), it works from the source file alone and does not need your compile classpath.
+
+New imports are appended after the existing ones, so run this before ``:
+
+```xml
+
+
+
+```
+
+Before:
+
+```java
+package com.acme;
+
+public class UserService {
+ private final java.util.Map> cache = new java.util.HashMap<>();
+
+ public java.util.List getUsers(java.util.function.Predicate filter) throws java.io.IOException {
+ return new java.util.ArrayList<>();
+ }
+}
+```
+
+After:
+
+```java
+package com.acme;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+public class UserService {
+ private final Map> cache = new HashMap<>();
+
+ public List getUsers(Predicate filter) throws IOException {
+ return new ArrayList<>();
+ }
+}
+```
+
+A name is left fully-qualified whenever shortening it could change what the code means:
+
+- two different fully-qualified names in the file would collapse to the same simple name (e.g. `java.util.List` and `java.awt.List`)
+- an existing import already binds that simple name to a different type
+- the file does not parse
+
+Types in `java.lang` and in the file's own package are shortened without adding an import.
+
### google-java-format
[homepage](https://github.com/google/google-java-format). [changelog](https://github.com/google/google-java-format/releases). [code](https://github.com/diffplug/spotless/blob/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/GoogleJavaFormat.java).
diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/Java.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/Java.java
index 9e31078eea..996dbefea7 100644
--- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/Java.java
+++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/Java.java
@@ -103,6 +103,10 @@ public void addTableTestFormatter(TableTestFormatter tableTestFormatter) {
addStepFactory(tableTestFormatter);
}
+ public void addShortenFullyQualifiedTypes(ShortenFullyQualifiedTypes shortenFullyQualifiedTypes) {
+ addStepFactory(shortenFullyQualifiedTypes);
+ }
+
private static String fileMask(Path path) {
String dir = path.toString();
if (!dir.endsWith(File.separator)) {
diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java
new file mode 100644
index 0000000000..83286608e1
--- /dev/null
+++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2025-2026 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.maven.java;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.java.ShortenFullyQualifiedTypesStep;
+import com.diffplug.spotless.maven.FormatterStepConfig;
+import com.diffplug.spotless.maven.FormatterStepFactory;
+
+public class ShortenFullyQualifiedTypes implements FormatterStepFactory {
+ @Override
+ public FormatterStep newFormatterStep(FormatterStepConfig config) {
+ return ShortenFullyQualifiedTypesStep.create(config.getProvisioner());
+ }
+}
diff --git a/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java b/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java
new file mode 100644
index 0000000000..c82bc839e4
--- /dev/null
+++ b/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java
@@ -0,0 +1,320 @@
+/*
+ * Copyright 2025-2026 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.java;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.LineEnding;
+import com.diffplug.spotless.TestProvisioner;
+
+class ShortenFullyQualifiedTypesStepTest {
+
+ private FormatterStep step() {
+ return ShortenFullyQualifiedTypesStep.create(TestProvisioner.mavenCentral());
+ }
+
+ private String apply(String input) throws Exception {
+ return step().format(LineEnding.toUnix(input), new File(""));
+ }
+
+ /** Returns the code portion (everything after imports/package), for asserting FQNs are gone from code only. */
+ private static String codeBody(String source) {
+ // Strip lines starting with package/import to avoid matching FQNs inside import statements
+ return source.lines()
+ .filter(l -> !l.stripLeading().startsWith("package ") && !l.stripLeading().startsWith("import "))
+ .reduce("", (a, b) -> a + "\n" + b);
+ }
+
+ @Test
+ void basicFqnShortening() throws Exception {
+ String before = String.join("\n",
+ "package com.example.service;",
+ "",
+ "public class UserService {",
+ " private final java.util.Map> cache = new java.util.HashMap<>();",
+ "",
+ " public java.util.List getUsers(java.util.function.Predicate filter) throws java.io.IOException {",
+ " java.util.List result = new java.util.ArrayList<>();",
+ " return result;",
+ " }",
+ "}",
+ "");
+ String result = apply(before);
+ // Verify FQNs are shortened in code (not in imports)
+ assertFalse(result.contains("java.util.Map<"), "java.util.Map should be shortened");
+ assertFalse(result.contains("java.util.List<"), "java.util.List should be shortened");
+ assertFalse(result.contains("new java.util.HashMap"), "java.util.HashMap should be shortened");
+ assertFalse(result.contains("new java.util.ArrayList"), "java.util.ArrayList should be shortened");
+ assertFalse(result.contains("throws java.io.IOException"), "java.io.IOException should be shortened in throws");
+ // Verify imports are added
+ assertTrue(result.contains("import java.util.Map;"), "should import Map");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ assertTrue(result.contains("import java.util.HashMap;"), "should import HashMap");
+ assertTrue(result.contains("import java.util.ArrayList;"), "should import ArrayList");
+ assertTrue(result.contains("import java.io.IOException;"), "should import IOException");
+ }
+
+ @Test
+ void conflictingSimpleNamesNotShortened() throws Exception {
+ String code = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " java.util.List a;",
+ " java.awt.List b;",
+ "}",
+ "");
+ assertEquals(code, apply(code));
+ }
+
+ @Test
+ void existingImportConflict() throws Exception {
+ String code = String.join("\n",
+ "package com.example;",
+ "",
+ "import java.awt.List;",
+ "",
+ "public class Foo {",
+ " java.util.List a;",
+ " List b;",
+ "}",
+ "");
+ assertEquals(code, apply(code));
+ }
+
+ @Test
+ void javaLangNotImported() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " java.lang.String s;",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(result.contains("java.lang.String"), "java.lang.String should be shortened");
+ assertFalse(result.contains("import java.lang.String"), "java.lang.String should not be imported");
+ }
+
+ @Test
+ void samePackageNotImported() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " com.example.Bar b;",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(result.contains("com.example.Bar"), "same-package FQN should be shortened");
+ assertFalse(result.contains("import com.example.Bar"), "same-package type should not be imported");
+ }
+
+ @Test
+ void alreadyImportedNotDuplicated() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "import java.util.List;",
+ "",
+ "public class Foo {",
+ " java.util.List a;",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(result.contains("java.util.List<"), "FQN should be shortened");
+ int count = result.split("import java\\.util\\.List;", -1).length - 1;
+ assertEquals(1, count, "should not duplicate import");
+ }
+
+ @Test
+ void noFqnUnchanged() throws Exception {
+ String code = String.join("\n",
+ "package com.example;",
+ "",
+ "import java.util.List;",
+ "",
+ "public class Foo {",
+ " List a;",
+ "}",
+ "");
+ assertEquals(code, apply(code));
+ }
+
+ // ── Java 14+ syntax tests ──────────────────────────────────────────
+
+ @Test
+ void instanceofPatternMatching() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " void test(Object o) {",
+ " if (o instanceof java.util.List> list) {",
+ " System.out.println(list);",
+ " }",
+ " }",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List"), "FQN in instanceof pattern should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should add import");
+ assertTrue(codeBody(result).contains("instanceof List> list"), "pattern variable should be preserved");
+ }
+
+ @Test
+ void instanceofChainedPatterns() throws Exception {
+ // Two instanceof patterns with FQNs on the same line
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " void test(Object a, Object b) {",
+ " if (a instanceof java.util.List> list",
+ " && b instanceof java.util.Map,?> map) {",
+ " System.out.println(list);",
+ " }",
+ " }",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List"), "FQN List should be shortened");
+ assertFalse(codeBody(result).contains("java.util.Map"), "FQN Map should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ assertTrue(result.contains("import java.util.Map;"), "should import Map");
+ }
+
+ @Test
+ void switchPatternMatching() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " String test(Object o) {",
+ " return switch (o) {",
+ " case java.util.List> list -> list.toString();",
+ " case java.util.Map,?> map -> map.toString();",
+ " default -> \"other\";",
+ " };",
+ " }",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List"), "FQN in switch case should be shortened");
+ assertFalse(codeBody(result).contains("java.util.Map"), "FQN in switch case should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ assertTrue(result.contains("import java.util.Map;"), "should import Map");
+ }
+
+ @Test
+ void recordComponents() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public record Pair(java.util.List left, java.util.Map right) {}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List"), "FQN in record component should be shortened");
+ assertFalse(codeBody(result).contains("java.util.Map"), "FQN in record component should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ assertTrue(result.contains("import java.util.Map;"), "should import Map");
+ }
+
+ @Test
+ void sealedPermitsNotCorrupted() throws Exception {
+ // sealed/permits are contextual keywords — ensure the step doesn't corrupt them
+ String code = String.join("\n",
+ "package com.example;",
+ "",
+ "public sealed interface Shape permits Circle, Square {}",
+ "");
+ assertEquals(code, apply(code));
+ }
+
+ @Test
+ void textBlockWithFqnUntouched() throws Exception {
+ String code = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " String s = \"\"\"",
+ " java.util.List is a type",
+ " \"\"\";",
+ "}",
+ "");
+ assertEquals(code, apply(code));
+ }
+
+ @Test
+ void varWithFqnInGenerics() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " void test() {",
+ " var list = new java.util.ArrayList>();",
+ " }",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.ArrayList"), "FQN ArrayList should be shortened");
+ assertFalse(codeBody(result).contains("java.util.Map"), "FQN Map in generic should be shortened");
+ assertTrue(result.contains("import java.util.ArrayList;"), "should import ArrayList");
+ assertTrue(result.contains("import java.util.Map;"), "should import Map");
+ }
+
+ @Test
+ void lambdaParameterTypes() throws Exception {
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " Runnable r = () -> {",
+ " java.util.List items = new java.util.ArrayList<>();",
+ " items.forEach((java.util.function.Consumer) s -> {});",
+ " };",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List<"), "FQN in lambda body should be shortened");
+ assertFalse(codeBody(result).contains("java.util.function.Consumer"), "FQN cast in lambda should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ }
+
+ @Test
+ void multipleAnnotationsWithFqn() throws Exception {
+ // FQNs used as annotation types should NOT be treated as type references
+ // (annotations start with @, not handled by ClassOrInterfaceType)
+ // but FQN types in annotation values or alongside annotations should work
+ String before = String.join("\n",
+ "package com.example;",
+ "",
+ "public class Foo {",
+ " java.util.List items;",
+ "}",
+ "");
+ String result = apply(before);
+ assertFalse(codeBody(result).contains("java.util.List"), "FQN should be shortened");
+ assertTrue(result.contains("import java.util.List;"), "should import List");
+ }
+}