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"); + } +}