From 3f747df89bd1c5062e56079e116b2f9fc05d4445 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Fri, 31 Jul 2026 18:43:22 +0200 Subject: [PATCH] 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"); + } +}