From 3f747df89bd1c5062e56079e116b2f9fc05d4445 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Fri, 31 Jul 2026 18:43:22 +0200 Subject: [PATCH 1/8] Add shortenFullyQualifiedTypes step (fixes #2945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new Java formatter step that replaces fully qualified type names with simple names and adds the required imports. For example: java.util.List items = new java.util.ArrayList<>(); becomes: import java.util.List; import java.util.ArrayList; ... List items = new ArrayList<>(); Uses JavaParser for AST-based type identification — no false positives from strings, comments, or non-type contexts. Position-based text replacement preserves original formatting. Safety: skips when simple names conflict (two FQNs → same short name), when an existing import claims the name for a different type, and when the file can't be parsed. java.lang and same-package types are shortened without adding imports. Tested with Java 14–25 syntax: instanceof pattern matching, switch patterns, records, sealed classes, text blocks, var with generics, and lambda casts (16 tests). Wired into both Gradle (shortenFullyQualifiedTypes()) and Maven (), designed to run before importOrder() and removeUnusedImports(). New files: - lib/.../java/ShortenFullyQualifiedTypesStep.java (step class) - lib/.../glue/javaparser/ShortenQualifiedTypesFormatterFunc.java (glue) - plugin-maven/.../java/ShortenFullyQualifiedTypes.java (Maven factory) - testlib/.../java/ShortenFullyQualifiedTypesStepTest.java (16 tests) Modified: - plugin-gradle/.../JavaExtension.java (+shortenFullyQualifiedTypes()) - plugin-maven/.../java/Java.java (+addShortenFullyQualifiedTypes()) --- .../ShortenQualifiedTypesFormatterFunc.java | 262 ++++++++++++++ .../java/ShortenFullyQualifiedTypesStep.java | 82 +++++ .../gradle/spotless/JavaExtension.java | 6 + .../diffplug/spotless/maven/java/Java.java | 4 + .../java/ShortenFullyQualifiedTypes.java | 28 ++ .../ShortenFullyQualifiedTypesStepTest.java | 321 ++++++++++++++++++ 6 files changed, 703 insertions(+) create mode 100644 lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java create mode 100644 lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java create mode 100644 plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java create mode 100644 testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java 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..1d6b1ea665 --- /dev/null +++ b/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java @@ -0,0 +1,262 @@ +/* + * Copyright 2025 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.Optional; +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 VoidVisitorAdapter() { + @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)); + } + } + }, 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) {} + + 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..3488e2cd47 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025 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/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/JavaExtension.java index 4bdbd5266d..7688613770 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 @@ -48,6 +48,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 { @@ -169,6 +170,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/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..b5d2e92de7 --- /dev/null +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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..d3c2184259 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java @@ -0,0 +1,321 @@ +/* + * Copyright 2025 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.StepHarness; +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"); + } +} From b395fd1adf54fa6861ee38ab02c76e96cca971b5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 15:48:44 -0700 Subject: [PATCH 2/8] docs: changelog entries and README matrix row for shortenFullyQualifiedTypes Adds the three changelog entries (lib, plugin-gradle, plugin-maven) and a row in the root README's freshmark-generated feature matrix, updating both the freshmark source block and the generated table. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGES.md | 2 ++ README.md | 2 ++ plugin-gradle/CHANGES.md | 2 ++ plugin-maven/CHANGES.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1840598f80..cc17c569f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ 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)) ### 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)) 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/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index bc43b6c43a..4b0294117a 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,8 @@ 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)) ### 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)) diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 549c6b6232..38084d1016 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ 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)) ### 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)) From cd57b5872660e32be849ba03769a63130da3a2d6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 16:04:52 -0700 Subject: [PATCH 3/8] docs: document shortenFullyQualifiedTypes in the plugin READMEs Adds a section to plugin-gradle/README.md and plugin-maven/README.md next to the other import-management steps, with a before/after example and the cases where a name is deliberately left fully-qualified. Also lists the step in each plugin's main Java config block, ahead of importOrder, since new imports are appended unsorted. Co-Authored-By: Claude Opus 5 (1M context) --- plugin-gradle/README.md | 64 +++++++++++++++++++++++++++++++++++++++++ plugin-maven/README.md | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 15c0f91817..433d70a24d 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -198,6 +198,9 @@ Spotless is primarily a formatter, _not_ a linter. In our opinion, a linter is j ```gradle spotless { java { + // replaces fully-qualified type names with simple names + imports, see below + shortenFullyQualifiedTypes() + // Use the default importOrder configuration importOrder() // optional: you can specify import groups directly @@ -286,6 +289,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-maven/README.md b/plugin-maven/README.md index 5479b01adf..a4c6978ba0 100644 --- a/plugin-maven/README.md +++ b/plugin-maven/README.md @@ -219,6 +219,8 @@ any other maven phase (i.e. compile) then it can be configured as below; + + false @@ -276,6 +278,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). From 1c5bc0aea337ee1e949611e35523933ed3ce4dba Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 16:15:18 -0700 Subject: [PATCH 4/8] docs: list the Java import steps in the plugin README tables of contents The Java TOC line in both plugin READMEs listed the formatters but none of the import-management steps. Adds shortenFullyQualifiedTypes along with the four that were already undocumented in the TOC: removeUnusedImports, forbidWildcardImports, expandWildcardImports, and forbidModuleImports. Co-Authored-By: Claude Opus 5 (1M context) --- plugin-gradle/README.md | 2 +- plugin-maven/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 433d70a24d..8fcc0fb0de 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)) diff --git a/plugin-maven/README.md b/plugin-maven/README.md index a4c6978ba0..9ef598c7f6 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)) From 0c49e657461527c70e46f11df2aac9585f05f529 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 16:20:46 -0700 Subject: [PATCH 5/8] chore: spotlessApply Removes two unused imports (java.util.Optional, StepHarness), updates the copyright headers to 2025-2026, and applies the project's eclipse format to the new files. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShortenQualifiedTypesFormatterFunc.java | 3 +- .../java/ShortenFullyQualifiedTypesStep.java | 29 +++++++++---------- .../java/ShortenFullyQualifiedTypes.java | 2 +- .../ShortenFullyQualifiedTypesStepTest.java | 3 +- 4 files changed, 17 insertions(+), 20 deletions(-) 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 index 1d6b1ea665..ed7dcbe6f1 100644 --- a/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java +++ b/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 DiffPlug + * 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. @@ -21,7 +21,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.TreeSet; import java.util.regex.Matcher; diff --git a/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java b/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java index 3488e2cd47..a24e200b66 100644 --- a/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java +++ b/lib/src/main/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStep.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 DiffPlug + * 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. @@ -63,20 +63,19 @@ private State equalityState() { private record State(JarState jarState) implements Serializable { - @Serial - private static final long serialVersionUID = 1L; + @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); - } + 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-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/java/ShortenFullyQualifiedTypes.java index b5d2e92de7..83286608e1 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025 DiffPlug + * 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. diff --git a/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java b/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java index d3c2184259..c82bc839e4 100644 --- a/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/java/ShortenFullyQualifiedTypesStepTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 DiffPlug + * 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. @@ -25,7 +25,6 @@ import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.LineEnding; -import com.diffplug.spotless.StepHarness; import com.diffplug.spotless.TestProvisioner; class ShortenFullyQualifiedTypesStepTest { From 45bea6db6b6d7ff28104b942f4c6b4e1af900328 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 16:52:51 -0700 Subject: [PATCH 6/8] Better place to put the shortenFullyQualifiedTypes docs --- plugin-gradle/README.md | 4 +--- plugin-maven/README.md | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 8fcc0fb0de..29b3a8120a 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -198,9 +198,6 @@ Spotless is primarily a formatter, _not_ a linter. In our opinion, a linter is j ```gradle spotless { java { - // replaces fully-qualified type names with simple names + imports, see below - shortenFullyQualifiedTypes() - // Use the default importOrder configuration importOrder() // optional: you can specify import groups directly @@ -213,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 diff --git a/plugin-maven/README.md b/plugin-maven/README.md index 9ef598c7f6..42492e78f9 100644 --- a/plugin-maven/README.md +++ b/plugin-maven/README.md @@ -219,8 +219,6 @@ any other maven phase (i.e. compile) then it can be configured as below; - - false @@ -238,6 +236,7 @@ any other maven phase (i.e. compile) then it can be configured as below; + From 4430823a4457bd7373a0e1bcd6ee146606942eed Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 16:56:22 -0700 Subject: [PATCH 7/8] Better organization on the changelogs. --- CHANGES.md | 2 +- plugin-gradle/CHANGES.md | 2 +- plugin-maven/CHANGES.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index cc17c569f1..fcd3982bbc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,12 +12,12 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [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/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 4b0294117a..b065710655 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -5,6 +5,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [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)) @@ -12,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-maven/CHANGES.md b/plugin-maven/CHANGES.md index 38084d1016..df3dab9695 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -5,17 +5,17 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [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 From 6b42c5e4ae3b5de295e8e95d6072e6fcc7a09dc6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 16 Aug 2026 18:01:55 -0700 Subject: [PATCH 8/8] fix: make the FQN-collecting visitor a named static class SpotBugs (SIC_INNER_SHOULD_BE_STATIC_ANON) failed :lib:spotbugsJavaParser because the anonymous VoidVisitorAdapter captured the enclosing ShortenQualifiedTypesFormatterFunc instance without ever using it. Lift it into a named static class taking its state as constructor args, matching ExpandWildcardsFormatterFunc.CollectImportedTypesVisitor. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShortenQualifiedTypesFormatterFunc.java | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) 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 index ed7dcbe6f1..510c205d81 100644 --- a/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java +++ b/lib/src/javaParser/java/com/diffplug/spotless/glue/javaparser/ShortenQualifiedTypesFormatterFunc.java @@ -81,36 +81,7 @@ public String apply(String rawUnix) throws Exception { Map> simpleToFqns = new LinkedHashMap<>(); List qualifiedRefs = new ArrayList<>(); - cu.accept(new VoidVisitorAdapter() { - @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)); - } - } - }, null); + cu.accept(new CollectQualifiedTypesVisitor(simpleToFqns, qualifiedRefs), null); if (qualifiedRefs.isEmpty()) { return rawUnix; @@ -200,6 +171,46 @@ public void visit(ClassOrInterfaceType type, Void arg) { 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);