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