Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ This document is intended for Spotless developers.
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`).

## [Unreleased]
### Added
- New `ShortenFullyQualifiedTypesStep` for Java, which replaces fully-qualified type names with their simple names and adds the imports they need. Uses JavaParser to find type references in the AST, so occurrences in strings, comments, and other non-type contexts are left alone. ([#2945](https://github.com/diffplug/spotless/issues/2945))
- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
### Fixed
- `removeUnusedImports` no longer throws on Java `import module` declarations (`JCModuleImport` ClassCastException). ([#2890](https://github.com/diffplug/spotless/issues/2890))
- Concurrent P2 provisioning (parallel multi-project Gradle fingerprinting of `eclipse()` / `greclipse()` steps) no longer races Solstice's on-disk cache; also `ConfigurationCacheHackList.toString()` no longer evaluates step state (which could re-trigger provisioning while Gradle reports "cannot be serialized"). ([#3004](https://github.com/diffplug/spotless/issues/3004))
### Changes
- Default `google-java-format` remains `1.28.0` on JVM 17; bumps to `1.30.0` on JVM 21+; require at least `1.30.0` on JVM 25+ for `import module` support.
- Add embedded lockfiles to Eclipse JDT for every supported version (`4.9` through `4.40`), so `eclipse()` resolves from Maven Central instead of querying a P2 update site. Versions without an embedded lockfile still fall back to P2 provisioning. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `eclipse` version to latest `4.39` -> `4.40`. ([#1996](https://github.com/diffplug/spotless/issues/1996))
- Bump default `adocfmt` version `0.2.0` -> `0.3.1`, which adds table formatting support (`formatTables`, `tableLayout`, `tableMaxLineWidth`, `tableBlankLines`).

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}} |',
Expand Down Expand Up @@ -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: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/*
* Copyright 2025-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.spotless.glue.javaparser;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.Position;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.PackageDeclaration;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;

import com.diffplug.spotless.FormatterFunc;

/**
* Uses JavaParser to identify fully qualified type references in the AST,
* then performs text-level replacement to shorten them and add imports.
*
* <p>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<CompilationUnit> 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<String, String> existingImportsBySimple = new LinkedHashMap<>();
Set<String> 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<String, Set<String>> simpleToFqns = new LinkedHashMap<>();
List<QualifiedTypeRef> qualifiedRefs = new ArrayList<>();

cu.accept(new CollectQualifiedTypesVisitor(simpleToFqns, qualifiedRefs), null);

if (qualifiedRefs.isEmpty()) {
return rawUnix;
}

// 4. Determine which FQNs are safe to shorten
Set<String> safeToShorten = new LinkedHashSet<>();
for (Map.Entry<String, Set<String>> entry : simpleToFqns.entrySet()) {
String simple = entry.getKey();
Set<String> 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<Integer, int[]> 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<int[]> 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<String> newImports = new TreeSet<>();
for (String fqn : safeToShorten) {
if (fqn.startsWith("java.lang.") && fqn.indexOf('.', 10) == -1) {
continue;
}
if (!packageName.isEmpty() && fqn.startsWith(packageName + ".")
&& fqn.indexOf('.', packageName.length() + 1) == -1) {
continue;
}
if (existingImportFqns.contains(fqn)) {
continue;
}
newImports.add(fqn);
}

if (!newImports.isEmpty()) {
String result = sb.toString();
int insertPos = findImportInsertPosition(result);
boolean afterExistingImport = IMPORT_LINE.matcher(result).find();

StringBuilder importBlock = new StringBuilder();
if (!afterExistingImport) {
importBlock.append('\n');
}
for (String fqn : newImports) {
importBlock.append("\nimport ").append(fqn).append(';');
}
sb = new StringBuilder(result);
sb.insert(insertPos, importBlock);
}

return sb.toString();
}

private record QualifiedTypeRef(String fqn, String simpleName, Position scopeStart, Position nameStart) {}

/** Collects the outermost fully-qualified type nodes, along with the text range of the scope to remove. */
private static final class CollectQualifiedTypesVisitor extends VoidVisitorAdapter<Void> {
private final Map<String, Set<String>> simpleToFqns;
private final List<QualifiedTypeRef> qualifiedRefs;

CollectQualifiedTypesVisitor(Map<String, Set<String>> simpleToFqns, List<QualifiedTypeRef> qualifiedRefs) {
this.simpleToFqns = simpleToFqns;
this.qualifiedRefs = qualifiedRefs;
}

@Override
public void visit(ClassOrInterfaceType type, Void arg) {
super.visit(type, arg);
if (type.getScope().isEmpty()) {
return;
}
// Skip types that are themselves the scope of a parent type
if (type.getParentNode().isPresent()
&& type.getParentNode().get() instanceof ClassOrInterfaceType parent
&& parent.getScope().isPresent()
&& parent.getScope().get() == type) {
return;
}
String rawName = buildRawName(type);
if (!startsWithPackage(rawName)) {
return;
}
String simple = type.getNameAsString();
simpleToFqns.computeIfAbsent(simple, k -> new LinkedHashSet<>()).add(rawName);

// Record the text range of the scope (to be removed)
ClassOrInterfaceType scope = type.getScope().get();
if (scope.getBegin().isPresent() && type.getName().getBegin().isPresent()) {
Position scopeStart = scope.getBegin().get();
Position nameStart = type.getName().getBegin().get();
qualifiedRefs.add(new QualifiedTypeRef(rawName, simple, scopeStart, nameStart));
}
}
}

private static String buildRawName(ClassOrInterfaceType type) {
StringBuilder sb = new StringBuilder();
buildRawNameRecursive(type, sb);
return sb.toString();
}

private static void buildRawNameRecursive(ClassOrInterfaceType type, StringBuilder sb) {
if (type.getScope().isPresent()) {
buildRawNameRecursive(type.getScope().get(), sb);
sb.append('.');
}
sb.append(type.getNameAsString());
}

private static boolean startsWithPackage(String rawName) {
return !rawName.isEmpty() && Character.isLowerCase(rawName.charAt(0));
}

/** Builds an array where lineOffsets[line] is the char offset of the start of that line (1-indexed). */
private static int[] buildLineOffsets(String text) {
List<Integer> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2025-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.spotless.java;

import static com.diffplug.spotless.JarState.from;
import static com.diffplug.spotless.JarState.promise;
import static java.util.Objects.requireNonNull;

import java.io.Serial;
import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;

import com.diffplug.spotless.FormatterFunc;
import com.diffplug.spotless.FormatterStep;
import com.diffplug.spotless.JarState;
import com.diffplug.spotless.Provisioner;

/**
* Replaces fully qualified type names with simple names and adds the necessary imports.
* Uses JavaParser to identify type references in the AST, avoiding false positives
* in strings, comments, annotations, and other non-type contexts.
*
* <p>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);
}
}
}}
Loading
Loading