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