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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (


### Fixed
- `shortenFullyQualifiedTypes` no longer corrupts a method reference on a field/variable (`localVar.field::method`), which JavaParser parses as a type-shaped scope; such scopes now go through the same known-package / minimum-depth heuristic as other expression scopes before being treated as fully-qualified types. ([#3133](https://github.com/diffplug/spotless/issues/3133))
- `shortenFullyQualifiedTypes` leaves a qualified name alone when the enclosing type extends or implements a type that is not in the same file, so an inherited member type cannot take the simple name. ([#3117](https://github.com/diffplug/spotless/issues/3117))

## [4.10.3] - 2026-09-25
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.FieldAccessExpr;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.MethodReferenceExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.expr.TypeExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;

Expand Down Expand Up @@ -290,6 +292,13 @@ public void visit(ClassOrInterfaceType type, Void arg) {
return;
}
String simple = type.getNameAsString();
// A method-reference scope (`scope::method`) is parsed as a type even when it is really a
// field access on an expression (e.g. `localVar.field::get`). Apply the same known-package /
// minimum-depth heuristic used for the other expression scopes before trusting it as a
// fully-qualified type (issue #3133).
if (isMethodReferenceScope(type) && !isTrustedFullyQualifiedName(rawName, knownPackages)) {
return;
}
simpleToFqns.computeIfAbsent(simple, k -> new LinkedHashSet<>()).add(rawName);

// Record the text range of the scope (to be removed)
Expand Down Expand Up @@ -353,9 +362,7 @@ private void processExpressionScope(Expression expr) {
fqn.append('.').append(chain.get(i).getNameAsString());
}
String fqnStr = fqn.toString();
String candidatePackage = fqnStr.substring(0, fqnStr.lastIndexOf('.'));
// Trust if package is known from imports; otherwise require ≥2 package segments
if (!knownPackages.contains(candidatePackage) && (typeIdx + 1) < 2) {
if (!isTrustedFullyQualifiedName(fqnStr, knownPackages)) {
return;
}
String simple = chain.get(typeIdx).getNameAsString();
Expand Down Expand Up @@ -483,6 +490,60 @@ private static boolean insideUnknownInheritance(Node node, Set<TypeDeclaration<?
return false;
}

/**
* True if {@code type} is the scope (qualifier) of a method reference, i.e. the
* {@code scope} in {@code scope::method}. JavaParser parses such a scope as a
* {@link ClassOrInterfaceType} even when it is really a field access on an
* expression (e.g. {@code localVar.field::get}), so callers must verify it is a
* real type before shortening it.
*/
private static boolean isMethodReferenceScope(ClassOrInterfaceType type) {
ClassOrInterfaceType outer = type;
while (outer.getParentNode().isPresent()
&& outer.getParentNode().get() instanceof ClassOrInterfaceType parent
&& parent.getScope().isPresent()
&& parent.getScope().get() == outer) {
outer = parent;
}
if (outer.getParentNode().isEmpty() || !(outer.getParentNode().get() instanceof TypeExpr typeExpr)) {
return false;
}
return typeExpr.getParentNode().isPresent()
&& typeExpr.getParentNode().get() instanceof MethodReferenceExpr methodRef
&& methodRef.getScope() == typeExpr;
}

/**
* Decides whether a dotted name is a trustworthy fully-qualified <em>type</em>
* reference, mirroring the expression-scope heuristic: the first upper-case
* segment is the type; everything before it is the package. We trust it when the
* package is already known (from imports, the file's own package, or
* {@code java.lang}) or when there are at least two package segments. This
* rejects {@code variable.Field} / {@code variable.field} shapes that only look
* like a fully-qualified type.
*/
private static boolean isTrustedFullyQualifiedName(String dottedName, Set<String> knownPackages) {
String[] segments = dottedName.split("\\.");
int typeIdx = -1;
for (int i = 0; i < segments.length; i++) {
if (!segments[i].isEmpty() && Character.isUpperCase(segments[i].charAt(0))) {
typeIdx = i;
break;
}
}
if (typeIdx <= 0) {
return false;
}
StringBuilder pkg = new StringBuilder();
for (int i = 0; i < typeIdx; i++) {
if (i > 0) {
pkg.append('.');
}
pkg.append(segments[i]);
}
return knownPackages.contains(pkg.toString()) || typeIdx >= 2;
}

private static String buildRawName(ClassOrInterfaceType type) {
StringBuilder sb = new StringBuilder();
buildRawNameRecursive(type, sb);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -663,4 +663,71 @@ void multipleAnnotationsWithFqn() throws Exception {
assertFalse(codeBody(result).contains("java.util.List"), "FQN should be shortened");
assertTrue(result.contains("import java.util.List;"), "should import List");
}

@Test
void issue3133_methodReferenceOnFieldNotShortened() throws Exception {
// `localVar.field::method` is a field access on an expression, parsed by JavaParser
// as a ClassOrInterfaceType scope. It must NOT be treated as a fully-qualified type.
String before = String.join("\n",
"package com.example;",
"",
"import java.util.Map;",
"import java.util.function.Function;",
"",
"public class Example {",
" static class Catalog {",
" Map<String, String> aliases;",
" }",
"",
" String demo(Catalog catalog) {",
" return find(catalog.aliases::get);",
" }",
"",
" String find(Function<String, String> f) {",
" return f.apply(\"x\");",
" }",
"}",
"");
String result = apply(before);
assertTrue(result.contains("catalog.aliases::get"), "field method reference must be left untouched");
assertFalse(result.contains("import catalog."), "must not add a bogus import: " + result);
}

@Test
void methodReferenceOnRealTypeStillShortened() throws Exception {
// A genuine fully-qualified type method reference should still be shortened.
String before = String.join("\n",
"package com.example;",
"",
"import java.util.function.Function;",
"",
"public class Example {",
" Function<String, Integer> f() {",
" return java.lang.Integer::parseInt;",
" }",
"}",
"");
String result = apply(before);
assertFalse(result.contains("java.lang.Integer::parseInt"), "FQN type method reference should be shortened");
assertTrue(result.contains("Integer::parseInt"), "should shorten to Integer::parseInt");
}

@Test
void methodReferenceWithSingleSegmentScopeLeftAlone() throws Exception {
// `config.Database::connect` has an upper-case simple name but a single-segment,
// non-package scope (e.g. a field access). Not a known package and < 2 package
// segments, so it must be left alone rather than shortened to a bogus import.
String before = String.join("\n",
"package com.example;",
"",
"public class Example {",
" Runnable r() {",
" return config.Database::connect;",
" }",
"}",
"");
String result = apply(before);
assertTrue(result.contains("config.Database::connect"), "single-segment non-package scope must be left alone");
assertFalse(result.contains("import config."), "must not add a bogus import: " + result);
}
}
Loading