This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new f65c867e4c GROOVY-11988: Add support for {@inheritDoc} in external JDK
classes
f65c867e4c is described below
commit f65c867e4c60e43236c0931d494848727c53e814
Author: Daniel Sun <[email protected]>
AuthorDate: Mon May 4 23:13:33 2026 +0900
GROOVY-11988: Add support for {@inheritDoc} in external JDK classes
---
.../tools/groovydoc/ExternalGroovyClassDoc.java | 64 ++-
.../tools/groovydoc/ExternalJavadocSupport.java | 617 +++++++++++++++++++++
.../groovy/tools/groovydoc/GroovyDocTool.java | 18 +-
.../tools/groovydoc/SimpleGroovyClassDoc.java | 40 +-
.../groovy/tools/groovydoc/TagRenderer.java | 123 +++-
.../groovy/tools/groovydoc/GroovyDocToolTest.java | 153 +++++
.../testfiles/JavaExtendsWriterInheritDoc.java | 39 ++
.../testfiles/JavaImplementsMapInheritDoc.java | 112 ++++
.../testfiles/JavaNestedResolutionOuter.java | 48 ++
.../JavaNestedResolutionSamePackageConsumer.java | 30 +
.../testfiles/JavaObjectCloneInheritDocChild.java | 27 +
.../sub/JavaNestedResolutionImportedConsumer.java | 32 ++
12 files changed, 1253 insertions(+), 50 deletions(-)
diff --git
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalGroovyClassDoc.java
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalGroovyClassDoc.java
index cbdbd3ef1b..577670cd35 100644
---
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalGroovyClassDoc.java
+++
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalGroovyClassDoc.java
@@ -27,6 +27,7 @@ import org.codehaus.groovy.groovydoc.GroovyMethodDoc;
import org.codehaus.groovy.groovydoc.GroovyPackageDoc;
import org.codehaus.groovy.groovydoc.GroovyType;
+import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
@@ -41,7 +42,7 @@ public class ExternalGroovyClassDoc implements GroovyClassDoc
{
private static final GroovyPackageDoc[] EMPTY_GROOVYPACKAGEDOC_ARRAY = new
GroovyPackageDoc[0];
private static final GroovyMethodDoc[] EMPTY_GROOVYMETHODDOC_ARRAY = new
GroovyMethodDoc[0];
private static final GroovyType[] EMPTY_GROOVYTYPE_ARRAY = new
GroovyType[0];
- private final Class externalClass;
+ private final Class<?> externalClass;
private final List<GroovyAnnotationRef> annotationRefs;
/**
@@ -49,7 +50,7 @@ public class ExternalGroovyClassDoc implements GroovyClassDoc
{
*
* @param externalClass the reflected class to represent
*/
- public ExternalGroovyClassDoc(Class externalClass) {
+ public ExternalGroovyClassDoc(Class<?> externalClass) {
this.externalClass = externalClass;
annotationRefs = new ArrayList<GroovyAnnotationRef>();
}
@@ -75,7 +76,8 @@ public class ExternalGroovyClassDoc implements GroovyClassDoc
{
*/
@Override
public String qualifiedTypeName() {
- return externalClass.getName();
+ String canonicalName = externalClass.getCanonicalName();
+ return canonicalName != null ? canonicalName : externalClass.getName();
}
/**
@@ -83,15 +85,14 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public GroovyClassDoc superclass() {
- Class aClass = externalClass.getSuperclass();
- if (aClass != null) return new ExternalGroovyClassDoc(aClass);
- return new ExternalGroovyClassDoc(Object.class);
+ Class<?> aClass = externalClass.getSuperclass();
+ return aClass != null ? new ExternalGroovyClassDoc(aClass) : null;
}
/**
* Returns the underlying reflected class.
*/
- public Class externalClass() {
+ public Class<?> externalClass() {
return externalClass;
}
@@ -107,7 +108,11 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public String simpleTypeName() {
- return qualifiedTypeName(); // TODO fix
+ String simpleName = externalClass.getSimpleName();
+ if (!simpleName.isEmpty()) return simpleName;
+ String qualifiedName = qualifiedTypeName();
+ int lastDot = qualifiedName.lastIndexOf('.');
+ return lastDot >= 0 ? qualifiedName.substring(lastDot + 1) :
qualifiedName;
}
/**
@@ -248,7 +253,14 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public GroovyClassDoc[] interfaces() {
- return EMPTY_GROOVYCLASSDOC_ARRAY;
+ Class<?>[] interfaces = externalClass.getInterfaces();
+ if (interfaces.length == 0) return EMPTY_GROOVYCLASSDOC_ARRAY;
+
+ GroovyClassDoc[] result = new GroovyClassDoc[interfaces.length];
+ for (int i = 0; i < interfaces.length; i++) {
+ result[i] = new ExternalGroovyClassDoc(interfaces[i]);
+ }
+ return result;
}
/**
@@ -264,7 +276,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isAbstract() {
- return false;
+ return Modifier.isAbstract(externalClass.getModifiers());
}
/**
@@ -280,7 +292,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isSerializable() {
- return false;
+ return java.io.Serializable.class.isAssignableFrom(externalClass);
}
/**
@@ -288,7 +300,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public GroovyMethodDoc[] methods() {
- return EMPTY_GROOVYMETHODDOC_ARRAY;
+ return ExternalJavadocSupport.methodsFor(this);
}
/**
@@ -296,7 +308,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public GroovyMethodDoc[] methods(boolean filter) {
- return EMPTY_GROOVYMETHODDOC_ARRAY;
+ return methods();
}
/**
@@ -360,7 +372,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isFinal() {
- return false;
+ return Modifier.isFinal(externalClass.getModifiers());
}
/**
@@ -376,7 +388,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isPrivate() {
- return false;
+ return Modifier.isPrivate(externalClass.getModifiers());
}
/**
@@ -384,7 +396,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isProtected() {
- return false;
+ return Modifier.isProtected(externalClass.getModifiers());
}
/**
@@ -392,7 +404,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isPublic() {
- return false;
+ return Modifier.isPublic(externalClass.getModifiers());
}
/**
@@ -400,7 +412,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isStatic() {
- return false;
+ return Modifier.isStatic(externalClass.getModifiers());
}
/**
@@ -416,7 +428,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public int modifierSpecifier() {
- return 0;
+ return externalClass.getModifiers();
}
/**
@@ -424,7 +436,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public String qualifiedName() {
- return null;
+ return externalClass.getName();
}
/**
@@ -448,7 +460,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isAnnotationType() {
- return false;
+ return externalClass.isAnnotation();
}
/**
@@ -464,7 +476,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isClass() {
- return false;
+ return !externalClass.isInterface() && !externalClass.isAnnotation()
&& !externalClass.isEnum() && !externalClass.isRecord();
}
/**
@@ -488,7 +500,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isEnum() {
- return false;
+ return externalClass.isEnum();
}
/**
@@ -496,7 +508,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isRecord() {
- return false;
+ return externalClass.isRecord();
}
/**
@@ -544,7 +556,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isInterface() {
- return false;
+ return externalClass.isInterface();
}
/**
@@ -560,7 +572,7 @@ public class ExternalGroovyClassDoc implements
GroovyClassDoc {
*/
@Override
public boolean isOrdinaryClass() {
- return false;
+ return isClass();
}
/**
diff --git
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalJavadocSupport.java
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalJavadocSupport.java
new file mode 100644
index 0000000000..bd6f13716e
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalJavadocSupport.java
@@ -0,0 +1,617 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc;
+
+import com.github.javaparser.JavaParser;
+import com.github.javaparser.ParseResult;
+import com.github.javaparser.ParserConfiguration;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.BodyDeclaration;
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.body.TypeDeclaration;
+import com.github.javaparser.ast.nodeTypes.NodeWithTypeParameters;
+import org.codehaus.groovy.groovydoc.GroovyMethodDoc;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Loads Javadoc for external classes (primarily JDK classes) from the local
+ * JDK source archive so {@code {@inheritDoc}} can be expanded when a Groovy
+ * source method overrides a method declared outside the documented source set.
+ */
+final class ExternalJavadocSupport {
+ private static final JavaParser JAVA_PARSER = new JavaParser(
+ new
ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)
+ );
+ private static final Path JDK_SRC_ZIP = detectJdkSrcZip();
+ private static final Map<Class<?>, Map<MethodKey, String>>
RAW_COMMENT_CACHE = new ConcurrentHashMap<>();
+ private static final Map<Class<?>, List<ExternalMethodData>> METHOD_CACHE
= new ConcurrentHashMap<>();
+ private static final Map<Class<?>, GroovyMethodDoc[]> METHOD_DOC_CACHE =
new ConcurrentHashMap<>();
+ private static final AtomicInteger ACTIVE_CACHE_SESSIONS = new
AtomicInteger();
+ private static final GroovyMethodDoc[] EMPTY_GROOVYMETHODDOC_ARRAY = new
GroovyMethodDoc[0];
+
+ private ExternalJavadocSupport() {
+ }
+
+ /**
+ * Returns the groovydoc representation of methods declared in the
external class,
+ * with comments resolved from the JDK source archive. External method
comments
+ * containing {@code {@inheritDoc}} are recursively resolved to their
parent
+ * class or interface method documentation.
+ *
+ * @param owner the external class documentation wrapper
+ * @return array of groovydoc method representations; empty if no methods
are found
+ */
+ static GroovyMethodDoc[] methodsFor(ExternalGroovyClassDoc owner) {
+ if (ACTIVE_CACHE_SESSIONS.get() == 0) {
+ try (CacheSession ignored = openCacheSession()) {
+ return cachedMethodDocsFor(owner.externalClass());
+ }
+ }
+ return cachedMethodDocsFor(owner.externalClass());
+ }
+
+ /**
+ * Opens a new cache session for external Javadoc loading. While a session
is
+ * active, external class method documentation and comment metadata are
cached
+ * and reused across multiple lookups. When the last session closes, all
caches
+ * are automatically cleared to avoid long-term memory retention in
long-lived
+ * Gradle daemons.
+ *
+ * <p>This method should be called at the start of a batch groovydoc
rendering
+ * operation that will perform multiple external inheritDoc lookups.</p>
+ *
+ * @return a {@link CacheSession} that must be closed (typically via
try-with-resources)
+ */
+ static CacheSession openCacheSession() {
+ ACTIVE_CACHE_SESSIONS.incrementAndGet();
+ return new CacheSession();
+ }
+
+ /**
+ * Returns current statistics about the state of all external Javadoc
caches,
+ * including the number of cached raw comment texts, method metadata
entries,
+ * and fully-materialized method doc arrays.
+ *
+ * @return a {@link CacheStats} snapshot capturing all three cache sizes
+ */
+ static CacheStats cacheStats() {
+ return new CacheStats(RAW_COMMENT_CACHE.size(), METHOD_CACHE.size(),
METHOD_DOC_CACHE.size());
+ }
+
+ /**
+ * Clears all external Javadoc caches. This method is automatically called
when
+ * the last active {@link CacheSession} is closed. It can also be called
manually
+ * to force a reset of cached data.
+ */
+ static void clearCaches() {
+ RAW_COMMENT_CACHE.clear();
+ METHOD_CACHE.clear();
+ METHOD_DOC_CACHE.clear();
+ }
+
+ private static GroovyMethodDoc[] cachedMethodDocsFor(Class<?>
externalClass) {
+ return METHOD_DOC_CACHE.computeIfAbsent(externalClass,
ExternalJavadocSupport::loadMethodDocs);
+ }
+
+ private static List<ExternalMethodData> loadExternalMethods(Class<?>
externalClass) {
+ Method[] declaredMethods = externalClass.getDeclaredMethods();
+ Arrays.sort(declaredMethods, Comparator.comparing(Method::getName)
+ .thenComparingInt(Method::getParameterCount)
+ .thenComparing(Method::toGenericString));
+
+ List<ExternalMethodData> result = new ArrayList<>();
+ for (Method method : declaredMethods) {
+ if (method.isSynthetic() || method.isBridge()) continue;
+ result.add(new ExternalMethodData(
+ method.getName(),
+ typeName(method.getReturnType()),
+ parameterTypeNames(method),
+ resolveEffectiveComment(externalClass, method, new
HashSet<>())
+ ));
+ }
+ return result;
+ }
+
+ private static GroovyMethodDoc[] loadMethodDocs(Class<?> externalClass) {
+ List<ExternalMethodData> methods =
METHOD_CACHE.computeIfAbsent(externalClass,
ExternalJavadocSupport::loadExternalMethods);
+ if (methods.isEmpty()) return EMPTY_GROOVYMETHODDOC_ARRAY;
+
+ ExternalGroovyClassDoc owner = new
ExternalGroovyClassDoc(externalClass);
+ GroovyMethodDoc[] docs = new GroovyMethodDoc[methods.size()];
+ for (int i = 0; i < methods.size(); i++) {
+ docs[i] = methods.get(i).toMethodDoc(owner);
+ }
+ return docs;
+ }
+
+ private static Map<MethodKey, String> loadMethodComments(Class<?>
externalClass) {
+ return RAW_COMMENT_CACHE.computeIfAbsent(externalClass,
ExternalJavadocSupport::parseMethodComments);
+ }
+
+ /**
+ * Manages the lifecycle of external Javadoc caches for a single groovydoc
render session.
+ * Implements reference counting: when the last session closes, all
external caches are
+ * cleared to prevent long-term memory retention in the Gradle daemon.
+ *
+ * <p>This class is not intended for public use; obtain instances via
+ * {@link ExternalJavadocSupport#openCacheSession()}.</p>
+ */
+ static final class CacheSession implements AutoCloseable {
+ private boolean closed;
+
+ @Override
+ public void close() {
+ if (closed) return;
+ closed = true;
+
+ int remaining = ACTIVE_CACHE_SESSIONS.decrementAndGet();
+ if (remaining <= 0) {
+ ACTIVE_CACHE_SESSIONS.set(0);
+ clearCaches();
+ }
+ }
+ }
+
+ /**
+ * Snapshot of current external Javadoc cache statistics. Contains the size
+ * of each of the three caches: raw comment text, method metadata, and
fully
+ * materialized method documentation arrays.
+ *
+ * <p>This class is immutable and used for diagnostics and testing.</p>
+ */
+ static final class CacheStats {
+ private final int rawCommentCacheSize;
+ private final int methodCacheSize;
+ private final int methodDocCacheSize;
+
+ private CacheStats(int rawCommentCacheSize, int methodCacheSize, int
methodDocCacheSize) {
+ this.rawCommentCacheSize = rawCommentCacheSize;
+ this.methodCacheSize = methodCacheSize;
+ this.methodDocCacheSize = methodDocCacheSize;
+ }
+
+ /**
+ * Returns the number of external classes with cached raw Javadoc
comment text.
+ *
+ * @return the size of the raw comment cache
+ */
+ int rawCommentCacheSize() {
+ return rawCommentCacheSize;
+ }
+
+ /**
+ * Returns the number of external classes with cached method metadata
(method names,
+ * parameter types, return types).
+ *
+ * @return the size of the method metadata cache
+ */
+ int methodCacheSize() {
+ return methodCacheSize;
+ }
+
+ /**
+ * Returns the number of external classes with cached
fully-materialized method
+ * documentation arrays ({@code GroovyMethodDoc[]}).
+ *
+ * @return the size of the method documentation cache
+ */
+ int methodDocCacheSize() {
+ return methodDocCacheSize;
+ }
+ }
+
+ private static Map<MethodKey, String> parseMethodComments(Class<?>
externalClass) {
+ Map<MethodKey, String> comments = new LinkedHashMap<>();
+ Optional<CompilationUnit> source = loadCompilationUnit(externalClass);
+ if (source.isEmpty()) return comments;
+
+ Optional<TypeDeclaration<?>> type = findTypeDeclaration(source.get(),
externalClass);
+ if (type.isEmpty()) return comments;
+
+ for (BodyDeclaration<?> member : type.get().getMembers()) {
+ if (!(member instanceof MethodDeclaration methodDeclaration))
continue;
+ Method reflectionMethod =
findMatchingDeclaredMethod(externalClass, methodDeclaration);
+ if (reflectionMethod == null) continue;
+ String raw = methodDeclaration.getJavadocComment()
+ .map(comment ->
normalizeJavadocComment(comment.getContent()))
+ .orElse("");
+ comments.put(MethodKey.of(reflectionMethod), raw);
+ }
+ return comments;
+ }
+
+ private static String resolveEffectiveComment(Class<?> ownerClass, Method
method, Set<ExternalMethodKey> visited) {
+ ExternalMethodKey key = new ExternalMethodKey(ownerClass,
MethodKey.of(method));
+ if (!visited.add(key)) return "";
+
+ String rawComment =
loadMethodComments(ownerClass).getOrDefault(key.methodKey(), "");
+ String trimmed = rawComment.trim();
+ if (!trimmed.contains("{@inheritDoc}")) return rawComment;
+
+ ExternalMethodMatch inherited = findInheritedMethod(ownerClass,
method, new HashSet<>());
+ if (inherited == null) {
+ return rawComment.replace("{@inheritDoc}", "").trim();
+ }
+
+ String inheritedComment =
resolveEffectiveComment(inherited.ownerClass(), inherited.method(), visited);
+ if (trimmed.equals("{@inheritDoc}")) {
+ return inheritedComment;
+ }
+ return rawComment.replace("{@inheritDoc}", inheritedComment);
+ }
+
+ private static Optional<CompilationUnit> loadCompilationUnit(Class<?>
externalClass) {
+ if (JDK_SRC_ZIP == null) return Optional.empty();
+ String entryName = sourceEntryName(externalClass);
+ if (entryName == null) return Optional.empty();
+
+ try (ZipFile zip = new ZipFile(JDK_SRC_ZIP.toFile())) {
+ ZipEntry entry = zip.getEntry(entryName);
+ if (entry == null) {
+ entry = findFallbackEntry(zip, entryName);
+ if (entry == null) return Optional.empty();
+ }
+ try (InputStream inputStream = zip.getInputStream(entry)) {
+ String source = new String(inputStream.readAllBytes(),
StandardCharsets.UTF_8);
+ ParseResult<CompilationUnit> result =
JAVA_PARSER.parse(source);
+ return result.getResult();
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static ZipEntry findFallbackEntry(ZipFile zip, String entryName) {
+ int slash = entryName.indexOf('/');
+ String suffix = slash >= 0 ? "/" + entryName.substring(slash + 1) :
"/" + entryName;
+ return zip.stream()
+ .filter(candidate -> candidate.getName().endsWith(suffix))
+ .findFirst()
+ .orElse(null);
+ }
+
+ private static Optional<TypeDeclaration<?>>
findTypeDeclaration(CompilationUnit compilationUnit, Class<?> externalClass) {
+ String packageName = externalClass.getPackageName();
+ String binaryName = externalClass.getName();
+ String relativeName = packageName.isEmpty() ? binaryName :
binaryName.substring(packageName.length() + 1);
+ String[] segments = relativeName.split("\\$");
+
+ TypeDeclaration<?> current = null;
+ for (TypeDeclaration<?> typeDeclaration : compilationUnit.getTypes()) {
+ if (typeDeclaration.getNameAsString().equals(segments[0])) {
+ current = typeDeclaration;
+ break;
+ }
+ }
+ if (current == null) return Optional.empty();
+
+ for (int i = 1; i < segments.length; i++) {
+ String segment = segments[i];
+ if (segment.chars().allMatch(Character::isDigit)) return
Optional.empty();
+ TypeDeclaration<?> next = null;
+ for (BodyDeclaration<?> member : current.getMembers()) {
+ if (member instanceof TypeDeclaration<?> nested &&
nested.getNameAsString().equals(segment)) {
+ next = nested;
+ break;
+ }
+ }
+ if (next == null) return Optional.empty();
+ current = next;
+ }
+ return Optional.of(current);
+ }
+
+ private static Method findMatchingDeclaredMethod(Class<?> externalClass,
MethodDeclaration methodDeclaration) {
+ Method[] declaredMethods = externalClass.getDeclaredMethods();
+ for (Method method : declaredMethods) {
+ if (!method.getName().equals(methodDeclaration.getNameAsString()))
continue;
+ if (method.isSynthetic() || method.isBridge()) continue;
+ if (method.getParameterCount() !=
methodDeclaration.getParameters().size()) continue;
+
+ boolean allMatch = true;
+ for (int i = 0; i < method.getParameterCount(); i++) {
+ String declaredType =
methodDeclaration.getParameter(i).getType().asString();
+ if (methodDeclaration.getParameter(i).isVarArgs()) {
+ declaredType += "[]";
+ }
+ if (!matchesTypeName(declaredType,
method.getParameterTypes()[i], methodDeclaration, externalClass)) {
+ allMatch = false;
+ break;
+ }
+ }
+ if (allMatch) return method;
+ }
+ return null;
+ }
+
+ private static boolean matchesTypeName(String declaredType, Class<?>
reflectedType, MethodDeclaration methodDeclaration, Class<?> externalClass) {
+ String normalizedDeclared = eraseTypeName(declaredType.replace("...",
"[]").trim());
+ if (normalizedDeclared.equals(typeName(reflectedType))) return true;
+ if (normalizedDeclared.equals(reflectedType.getSimpleName())) return
true;
+ if (normalizedDeclared.equals(reflectedType.getTypeName())) return
true;
+ if (reflectedType.getCanonicalName() != null &&
normalizedDeclared.equals(reflectedType.getCanonicalName())) return true;
+
+ Set<String> typeParameters = new HashSet<>();
+ methodDeclaration.getTypeParameters().forEach(type ->
typeParameters.add(type.getNameAsString()));
+ methodDeclaration.findAncestor(TypeDeclaration.class)
+ .ifPresent(type -> {
+ if (type instanceof NodeWithTypeParameters<?>
nodeWithTypeParameters) {
+ nodeWithTypeParameters.getTypeParameters()
+ .forEach(parameter ->
typeParameters.add(parameter.getNameAsString()));
+ }
+ });
+ if (typeParameters.contains(normalizedDeclared)) {
+ return reflectedType == Object.class;
+ }
+ if (normalizedDeclared.endsWith("[]")) {
+ String componentType = normalizedDeclared.substring(0,
normalizedDeclared.length() - 2);
+ if (typeParameters.contains(componentType)) {
+ return reflectedType.isArray() &&
reflectedType.getComponentType() == Object.class;
+ }
+ }
+
+ if (!normalizedDeclared.contains(".")) {
+ String packagePrefix = externalClass.getPackageName();
+ if (!packagePrefix.isEmpty() && (packagePrefix + "." +
normalizedDeclared).equals(typeName(reflectedType))) return true;
+ if (("java.lang." +
normalizedDeclared).equals(typeName(reflectedType))) return true;
+ }
+
+ return false;
+ }
+
+ private static String eraseTypeName(String declaredType) {
+ if (declaredType == null || declaredType.isEmpty()) return "";
+ StringBuilder erased = new StringBuilder(declaredType.length());
+ int genericDepth = 0;
+ for (int i = 0; i < declaredType.length(); i++) {
+ char ch = declaredType.charAt(i);
+ if (ch == '<') {
+ genericDepth++;
+ continue;
+ }
+ if (ch == '>') {
+ genericDepth--;
+ continue;
+ }
+ if (genericDepth == 0) {
+ erased.append(ch);
+ }
+ }
+ String normalized = erased.toString().trim();
+ if (normalized.startsWith("? extends ")) {
+ normalized = normalized.substring("? extends ".length()).trim();
+ } else if (normalized.startsWith("? super ")) {
+ normalized = normalized.substring("? super ".length()).trim();
+ } else if ("?".equals(normalized)) {
+ return Object.class.getSimpleName();
+ }
+ return normalized;
+ }
+
+ private static ExternalMethodMatch findInheritedMethod(Class<?>
ownerClass, Method method, Set<Class<?>> seen) {
+ Class<?> superclass = ownerClass.getSuperclass();
+ while (superclass != null && seen.add(superclass)) {
+ Method declared = findDeclaredMethod(superclass, method);
+ if (declared != null) return new ExternalMethodMatch(superclass,
declared);
+ superclass = superclass.getSuperclass();
+ }
+
+ ExternalMethodMatch direct = findInheritedInterfaceMethod(ownerClass,
method, seen);
+ if (direct != null) return direct;
+
+ for (Class<?> current = ownerClass.getSuperclass(); current != null;
current = current.getSuperclass()) {
+ ExternalMethodMatch inherited =
findInheritedInterfaceMethod(current, method, seen);
+ if (inherited != null) return inherited;
+ }
+ return null;
+ }
+
+ private static ExternalMethodMatch findInheritedInterfaceMethod(Class<?>
type, Method method, Set<Class<?>> seen) {
+ for (Class<?> iface : type.getInterfaces()) {
+ if (!seen.add(iface)) continue;
+ Method declared = findDeclaredMethod(iface, method);
+ if (declared != null) return new ExternalMethodMatch(iface,
declared);
+ ExternalMethodMatch deeper = findInheritedInterfaceMethod(iface,
method, seen);
+ if (deeper != null) return deeper;
+ }
+ return null;
+ }
+
+ private static Method findDeclaredMethod(Class<?> type, Method template) {
+ try {
+ Method method = type.getDeclaredMethod(template.getName(),
template.getParameterTypes());
+ return method.isSynthetic() || method.isBridge() ? null : method;
+ } catch (NoSuchMethodException ignored) {
+ return null;
+ }
+ }
+
+ private static String sourceEntryName(Class<?> externalClass) {
+ String binaryName = externalClass.getName();
+ String packageName = externalClass.getPackageName();
+ String relativeName = packageName.isEmpty() ? binaryName :
binaryName.substring(packageName.length() + 1);
+ int nested = relativeName.indexOf('$');
+ String topLevel = nested >= 0 ? relativeName.substring(0, nested) :
relativeName;
+ StringBuilder entry = new StringBuilder();
+ Module module = externalClass.getModule();
+ if (module != null && module.isNamed()) {
+ entry.append(module.getName()).append('/');
+ }
+ if (!packageName.isEmpty()) {
+ entry.append(packageName.replace('.', '/')).append('/');
+ }
+ entry.append(topLevel).append(".java");
+ return entry.toString();
+ }
+
+ private static String typeName(Class<?> type) {
+ if (type.isArray()) return typeName(type.getComponentType()) + "[]";
+ String canonicalName = type.getCanonicalName();
+ return canonicalName != null ? canonicalName : type.getTypeName();
+ }
+
+ private static String normalizeJavadocComment(String content) {
+ if (content == null || content.isEmpty()) return "";
+ String[] lines = content.replace("\r\n", "\n").replace('\r',
'\n').split("\n", -1);
+ int start = 0;
+ int end = lines.length;
+ while (start < end && lines[start].trim().isEmpty()) {
+ start++;
+ }
+ while (end > start && lines[end - 1].trim().isEmpty()) {
+ end--;
+ }
+ StringBuilder normalized = new StringBuilder();
+ for (int i = start; i < end; i++) {
+ if (normalized.length() > 0) normalized.append('\n');
+ normalized.append(lines[i].replaceFirst("^\\s*\\* ?", ""));
+ }
+ return normalized.toString().trim();
+ }
+
+ private static List<String> parameterTypeNames(Method method) {
+ Class<?>[] parameterTypes = method.getParameterTypes();
+ List<String> result = new ArrayList<>(parameterTypes.length);
+ for (Class<?> parameterType : parameterTypes) {
+ result.add(typeName(parameterType));
+ }
+ return result;
+ }
+
+ private static Path detectJdkSrcZip() {
+ String javaHome = System.getProperty("java.home");
+ if (javaHome == null || javaHome.isEmpty()) return null;
+
+ Path home = Path.of(javaHome);
+ Path direct = home.resolve("lib/src.zip");
+ if (Files.isRegularFile(direct)) return direct;
+
+ Path parent = home.getParent();
+ if (parent == null) return null;
+
+ Path sibling = parent.resolve("lib/src.zip");
+ return Files.isRegularFile(sibling) ? sibling : null;
+ }
+
+ /**
+ * Represents method metadata extracted from an external class (typically
JDK classes).
+ * Captures the method signature (name, parameter types, return type) and
its raw
+ * Javadoc comment text. Used as an intermediate representation before
converting
+ * to {@link SimpleGroovyMethodDoc} for rendering.
+ *
+ * <p>The raw comment text may contain {@code {@inheritDoc}} markers that
are
+ * expanded during cache construction.</p>
+ */
+ private static final class ExternalMethodData {
+ private final String name;
+ private final String returnTypeName;
+ private final List<String> parameterTypeNames;
+ private final String rawCommentText;
+
+ private ExternalMethodData(String name, String returnTypeName,
List<String> parameterTypeNames, String rawCommentText) {
+ this.name = name;
+ this.returnTypeName = returnTypeName;
+ this.parameterTypeNames = parameterTypeNames;
+ this.rawCommentText = rawCommentText;
+ }
+
+ /**
+ * Converts this method data into a fully-materialized {@link
SimpleGroovyMethodDoc}
+ * suitable for rendering by groovydoc templates. Sets up method name,
return type,
+ * parameters, and raw comment text.
+ *
+ * @param owner the groovydoc representation of the external class
that owns this method
+ * @return a groovydoc method representation with all fields populated
+ */
+ private GroovyMethodDoc toMethodDoc(ExternalGroovyClassDoc owner) {
+ SimpleGroovyMethodDoc methodDoc = new SimpleGroovyMethodDoc(name,
owner);
+ methodDoc.setReturnType(new SimpleGroovyType(returnTypeName));
+ for (int i = 0; i < parameterTypeNames.size(); i++) {
+ SimpleGroovyParameter parameter = new
SimpleGroovyParameter("arg" + i);
+ parameter.setType(new
SimpleGroovyType(parameterTypeNames.get(i)));
+ methodDoc.add(parameter);
+ }
+ methodDoc.setRawCommentText(rawCommentText);
+ return methodDoc;
+ }
+ }
+
+ /**
+ * Uniquely identifies a method within an external class by its name and
+ * parameter type names. Used as a cache key for storing and retrieving
+ * Javadoc comment text for specific methods.
+ *
+ * @param name the method name
+ * @param parameterTypeNames the qualified names of parameter types in
order
+ */
+ private record MethodKey(String name, List<String> parameterTypeNames) {
+ /**
+ * Creates a {@code MethodKey} from a reflected {@link Method}.
+ *
+ * @param method the reflected method
+ * @return a cache key representing this method
+ */
+ private static MethodKey of(Method method) {
+ return new MethodKey(method.getName(),
ExternalJavadocSupport.parameterTypeNames(method));
+ }
+ }
+
+ /**
+ * Uniquely identifies a method within a specific external class hierarchy
+ * by combining the owner class with a method key. Used during recursive
+ * resolution of {@code {@inheritDoc}} to prevent infinite loops when
+ * cyclic inheritance patterns are encountered.
+ *
+ * @param ownerClass the class declaring the method
+ * @param methodKey the method identifier (name and parameter types)
+ */
+ private record ExternalMethodKey(Class<?> ownerClass, MethodKey methodKey)
{
+ }
+
+ /**
+ * Represents a method found while walking an external class's inheritance
chain
+ * during {@code {@inheritDoc}} resolution. Pairs the class that declares
the method
+ * with the reflected method object itself.
+ *
+ * @param ownerClass the class in which this method is declared
+ * @param method the reflected method object
+ */
+ private record ExternalMethodMatch(Class<?> ownerClass, Method method) {
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/GroovyDocTool.java
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/GroovyDocTool.java
index dcb8bd9192..ded95f7253 100644
---
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/GroovyDocTool.java
+++
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/GroovyDocTool.java
@@ -172,14 +172,16 @@ public class GroovyDocTool {
if ("true".equals(properties.getProperty("privateScope")))
properties.setProperty("packageScope", "true");
if ("true".equals(properties.getProperty("packageScope")))
properties.setProperty("protectedScope", "true");
if ("true".equals(properties.getProperty("protectedScope")))
properties.setProperty("publicScope", "true");
- if (templateEngine != null) {
- GroovyDocWriter writer = new GroovyDocWriter(output,
templateEngine, properties, sourcepaths);
- GroovyRootDoc rootDoc = rootDocBuilder.getRootDoc();
- writer.writeRoot(rootDoc, destdir);
- writer.writePackages(rootDoc, destdir);
- writer.writeClasses(rootDoc, destdir);
- } else {
- throw new UnsupportedOperationException("No template engine was
found");
+ try (AutoCloseable ignored =
ExternalJavadocSupport.openCacheSession()) {
+ if (templateEngine != null) {
+ GroovyDocWriter writer = new GroovyDocWriter(output,
templateEngine, properties, sourcepaths);
+ GroovyRootDoc rootDoc = rootDocBuilder.getRootDoc();
+ writer.writeRoot(rootDoc, destdir);
+ writer.writePackages(rootDoc, destdir);
+ writer.writeClasses(rootDoc, destdir);
+ } else {
+ throw new UnsupportedOperationException("No template engine
was found");
+ }
}
}
diff --git
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
index 7a9cfd5ac5..9fdd2a51bc 100644
---
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
+++
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
@@ -774,6 +774,8 @@ public class SimpleGroovyClassDoc extends
SimpleGroovyAbstractableElementDoc imp
if (doc != null) return doc;
doc = resolveInternalClassDocFromSamePackage(rootDoc, name);
if (doc != null) return doc;
+ doc = resolveNestedClassDocFromEnclosingTypes(rootDoc, name);
+ if (doc != null) return doc;
for (GroovyClassDoc nestedDoc : nested) {
if (nestedDoc.name().endsWith("." + name))
return nestedDoc;
@@ -851,12 +853,32 @@ public class SimpleGroovyClassDoc extends
SimpleGroovyAbstractableElementDoc imp
return PRIMITIVES.contains(type);
}
+ private static String normalizeInternalTypeName(String name) {
+ return name.replace('$', '.');
+ }
+
+ private static int lastInternalNestedSeparator(String fullPathName) {
+ int lastSlash = fullPathName.lastIndexOf('/');
+ int lastDot = fullPathName.lastIndexOf('.');
+ return lastDot > lastSlash ? lastDot : -1;
+ }
+
private GroovyClassDoc resolveInternalClassDocFromImport(GroovyRootDoc
rootDoc, String baseName) {
if (isPrimitiveType(baseName)) return null;
+ String normalizedBaseName = normalizeInternalTypeName(baseName);
for (String importName : importedClassesAndPackages) {
String targetClassName = null;
if (aliases.containsKey(baseName)) {
targetClassName = aliases.get(baseName);
+ } else if (normalizedBaseName.contains(".")) {
+ int dot = normalizedBaseName.indexOf('.');
+ String outerName = normalizedBaseName.substring(0, dot);
+ String nestedSuffix = normalizedBaseName.substring(dot);
+ if (importName.endsWith("/" + outerName)) {
+ targetClassName = importName + nestedSuffix;
+ } else if (importName.endsWith("/*")) {
+ targetClassName = importName.substring(0,
importName.length() - 1) + normalizedBaseName;
+ }
} else if (importName.endsWith("/" + baseName)) {
targetClassName = importName;
} else if (importName.endsWith("/*")) {
@@ -882,11 +904,25 @@ public class SimpleGroovyClassDoc extends
SimpleGroovyAbstractableElementDoc imp
private GroovyClassDoc
resolveInternalClassDocFromSamePackage(GroovyRootDoc rootDoc, String baseName) {
if (isPrimitiveType(baseName)) return null;
- if (baseName.contains(".")) return null;
int lastSlash = fullPathName.lastIndexOf('/');
if (lastSlash < 0) return null;
String pkg = fullPathName.substring(0, lastSlash + 1);
- return ((SimpleGroovyRootDoc)rootDoc).classNamedExact(pkg + baseName);
+ String candidate = normalizeInternalTypeName(baseName);
+ return ((SimpleGroovyRootDoc)rootDoc).classNamedExact(pkg + candidate);
+ }
+
+ private GroovyClassDoc
resolveNestedClassDocFromEnclosingTypes(GroovyRootDoc rootDoc, String baseName)
{
+ if (rootDoc == null || fullPathName == null) return null;
+ String nestedSuffix = normalizeInternalTypeName(baseName);
+ String current = fullPathName;
+ int separator = lastInternalNestedSeparator(current);
+ while (separator >= 0) {
+ current = current.substring(0, separator);
+ GroovyClassDoc doc = ((SimpleGroovyRootDoc)
rootDoc).classNamedExact(current + "." + nestedSuffix);
+ if (doc != null) return doc;
+ separator = lastInternalNestedSeparator(current);
+ }
+ return null;
}
private Class resolveExternalClassFromImport(String name) {
diff --git
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
index 9122c9abcf..7fc8960192 100644
---
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
+++
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
@@ -874,7 +874,7 @@ final class TagRenderer {
private static boolean isKnownInlineTag(String name, Config cfg) {
return switch (name) {
- case "link", "see", "code", "interface", "value", "inheritDoc",
"snippet" -> true;
+ case "link", "linkplain", "see", "code", "interface", "value",
"inheritDoc", "return", "summary", "index", "snippet" -> true;
case "literal" -> cfg.literalEnabled;
default -> false;
};
@@ -893,6 +893,7 @@ final class TagRenderer {
// annotation declarations in comments don't pollute output.
return;
case "link":
+ case "linkplain":
case "see":
out.append(SimpleGroovyClassDoc.getDocUrl(body, false, links,
relPath, rootDoc, classDoc));
return;
@@ -905,6 +906,11 @@ final class TagRenderer {
case "code":
out.append(cfg.codeOpen).append(SimpleGroovyClassDoc.encodeAngleBrackets(body)).append(cfg.codeClose);
return;
+ case "return":
+ case "summary":
+ case "index":
+ out.append(renderInline(body, links, relPath, rootDoc,
classDoc, memberDoc, cfg, inheritDocVisited, inheritDocContext));
+ return;
case "value": {
// GROOVY-6016: resolve {@value #FIELD} or {@value
Class#FIELD}.
// Bare {@value} resolves to the enclosing field's own value
@@ -924,7 +930,9 @@ final class TagRenderer {
case "inheritDoc": {
// GROOVY-3782: only meaningful on a method; render the parent
// method's doc in the same inheritDoc expansion context so
- // cycles don't reset the visited set on re-entry.
+ // cycles don't reset the visited set on re-entry. A non-null
+ // result means the tag was handled; the empty string
suppresses
+ // a literal {@inheritDoc} when no inherited text is available.
String inherited = resolveInheritDoc(memberDoc, classDoc,
links, relPath, rootDoc, cfg, inheritDocVisited, inheritDocContext);
if (inherited != null) {
out.append(inherited);
@@ -944,8 +952,11 @@ final class TagRenderer {
* already-rendered comment text. Walks the superclass chain, then
* interfaces reachable from the current class or any superclass,
* looking for a method with the same name and matching parameter type
- * names. Returns {@code null} if the current member isn't a method,
- * no parent method is found, or the parent method has no doc.
+ * names. Returns {@code null} only when the current member isn't a
+ * method and the tag should therefore remain verbatim. Returns an empty
+ * string when the tag is recognized in a method context but no inherited
+ * text should be emitted, for example because no parent doc is available
+ * or a cycle was detected.
*
* <p>Recursion safety: the {@code visited} set tracks methods we've
* already expanded on this chain and is reused when rendering parent
@@ -961,20 +972,22 @@ final class TagRenderer {
Set<GroovyMethodDoc> visited,
InheritDocContext
inheritDocContext) {
if (!(memberDoc instanceof GroovyMethodDoc thisMethod)) return null;
- if (classDoc == null) return null;
+ if (classDoc == null) return "";
if (visited == null) visited = new HashSet<>();
if (!visited.add(thisMethod)) return ""; // cycle: suppress literal
{@inheritDoc}
GroovyMethodDoc parent = findInheritedMethod(thisMethod, classDoc, new
HashSet<>());
- if (parent == null) return null;
- if (parent instanceof SimpleGroovyMemberDoc parentMember
- && parentMember.belongsToClass instanceof SimpleGroovyClassDoc
parentClassDoc) {
+ if (parent == null) return "";
+ if (parent instanceof SimpleGroovyMemberDoc parentMember) {
+ SimpleGroovyClassDoc parentClassDoc = parentMember.belongsToClass
instanceof SimpleGroovyClassDoc
+ ? (SimpleGroovyClassDoc) parentMember.belongsToClass
+ : classDoc;
if (inheritDocContext != null) {
GroovyTag inheritedTag = findInheritedTag(parentMember,
inheritDocContext);
- if (inheritedTag == null) return null;
+ if (inheritedTag == null) return "";
return renderInline(inheritedTag.text(), links, relPath,
rootDoc, parentClassDoc, parentMember, cfg, visited, inheritDocContext);
}
- return
parentClassDoc.replaceTags(parentMember.getRawCommentText(), parentMember,
visited);
+ return render(parentMember.getRawCommentText(), links, relPath,
rootDoc, parentClassDoc, parentMember, cfg, visited);
}
String rendered = parent.commentText();
return rendered == null ? "" : rendered;
@@ -1049,19 +1062,101 @@ final class TagRenderer {
private static GroovyMethodDoc findMatchingMethod(GroovyClassDoc cls,
GroovyMethodDoc target) {
String targetName = target.name();
GroovyParameter[] targetParams = target.parameters();
+ GroovyMethodDoc compatibleMatch = null;
for (GroovyMethodDoc m : cls.methods()) {
if (!targetName.equals(m.name())) continue;
GroovyParameter[] params = m.parameters();
if (params.length != targetParams.length) continue;
- boolean allMatch = true;
+ boolean allExactMatch = true;
+ boolean allCompatible = true;
for (int i = 0; i < params.length; i++) {
String a = params[i].typeName();
String b = targetParams[i].typeName();
- if (!Objects.equals(a, b)) { allMatch = false; break; }
+ if (!typeNamesEqual(a, b)) {
+ allExactMatch = false;
+ if (!typeNamesCompatible(a, b)) {
+ allCompatible = false;
+ break;
+ }
+ }
}
- if (allMatch) return m;
+ if (allExactMatch) return m;
+ if (allCompatible && compatibleMatch == null) compatibleMatch = m;
}
- return null;
+ return compatibleMatch;
+ }
+
+ private static boolean typeNamesEqual(String left, String right) {
+ String a = normalizeTypeName(left);
+ String b = normalizeTypeName(right);
+ return Objects.equals(a, b) || Objects.equals(simpleTypeName(a),
simpleTypeName(b));
+ }
+
+ private static boolean typeNamesCompatible(String left, String right) {
+ String a = normalizeTypeName(left);
+ String b = normalizeTypeName(right);
+ if (Objects.equals(a, b) || Objects.equals(simpleTypeName(a),
simpleTypeName(b))) return true;
+ if (isTypeVariableName(a) && isTypeVariableName(b)) return true;
+ if ((isTypeVariableName(a) && isReferenceType(b)) ||
(isTypeVariableName(b) && isReferenceType(a))) {
+ return true;
+ }
+ if (isArrayType(a) && isArrayType(b)) {
+ String leftComponent = arrayComponentType(a);
+ String rightComponent = arrayComponentType(b);
+ if (typeNamesCompatible(leftComponent, rightComponent)) return
true;
+ if ((isObjectType(leftComponent) &&
isReferenceType(rightComponent))
+ || (isObjectType(rightComponent) &&
isReferenceType(leftComponent))) {
+ return true;
+ }
+ }
+ return (isObjectType(a) && !isPrimitiveType(b)) || (isObjectType(b) &&
!isPrimitiveType(a));
+ }
+
+ private static String normalizeTypeName(String typeName) {
+ if (typeName == null) return "";
+ String normalized = typeName.replace("...", "[]").trim();
+ int genericStart = normalized.indexOf('<');
+ if (genericStart >= 0) normalized = normalized.substring(0,
genericStart).trim();
+ if (normalized.startsWith("? extends ")) {
+ normalized = normalized.substring("? extends ".length()).trim();
+ } else if (normalized.startsWith("? super ")) {
+ normalized = normalized.substring("? super ".length()).trim();
+ } else if ("?".equals(normalized)) {
+ normalized = Object.class.getSimpleName();
+ }
+ return normalized;
+ }
+
+ private static String simpleTypeName(String typeName) {
+ int lastDot = typeName.lastIndexOf('.');
+ return lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
+ }
+
+ private static boolean isObjectType(String typeName) {
+ return "Object".equals(typeName) ||
"java.lang.Object".equals(typeName);
+ }
+
+ private static boolean isArrayType(String typeName) {
+ return typeName.endsWith("[]");
+ }
+
+ private static String arrayComponentType(String typeName) {
+ return typeName.substring(0, typeName.length() - 2);
+ }
+
+ private static boolean isPrimitiveType(String typeName) {
+ return switch (typeName) {
+ case "boolean", "byte", "char", "short", "int", "long", "float",
"double", "void" -> true;
+ default -> false;
+ };
+ }
+
+ private static boolean isReferenceType(String typeName) {
+ return !isPrimitiveType(typeName);
+ }
+
+ private static boolean isTypeVariableName(String typeName) {
+ return typeName.matches("[A-Z][0-9]?") || "KEY".equals(typeName) ||
"VALUE".equals(typeName);
}
private static String resolveValueTag(String body, GroovyRootDoc rootDoc,
SimpleGroovyClassDoc classDoc) {
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
index 52a9a9b283..8dd876411e 100644
---
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
@@ -823,6 +823,159 @@ public class GroovyDocToolTest extends GroovyTestCase {
invokeMethodSection.contains("{@inheritDoc}"));
}
+ public void testInheritDocResolvesFromExternalJdkAbstractClassInHtml()
throws Exception {
+ String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
+ htmlTool.add(List.of(base + "/JavaExtendsWriterInheritDoc.java"));
+
+ MockOutputTool output = new MockOutputTool();
+ htmlTool.renderToOutput(output, MOCK_DIR);
+
+ String doc = output.getText(MOCK_DIR + "/" + base +
"/JavaExtendsWriterInheritDoc.html");
+ String closeSection = findMethodSection(doc, "close", "");
+ String flushSection = findMethodSection(doc, "flush", "");
+ assertNotNull("Expected JavaExtendsWriterInheritDoc.html in output",
doc);
+ assertNotNull("Expected close() section in:\n" + doc, closeSection);
+ assertNotNull("Expected flush() section in:\n" + doc, flushSection);
+ assertTrue("Expected inherited close() text from java.io.Writer in:\n"
+ doc,
+ normalizeWhitespace(closeSection).contains("Closes the
stream"));
+ assertTrue("Expected inherited flush() text from java.io.Writer in:\n"
+ doc,
+ normalizeWhitespace(flushSection).contains("Flushes the
stream"));
+ assertFalse("External JDK inheritDoc should not remain literal in:\n"
+ doc,
+ doc.contains("{@inheritDoc}"));
+ }
+
+ public void testInheritDocResolvesFromExternalObjectMethodInHtml() throws
Exception {
+ String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
+ htmlTool.add(List.of(base + "/JavaObjectCloneInheritDocChild.java"));
+
+ MockOutputTool output = new MockOutputTool();
+ htmlTool.renderToOutput(output, MOCK_DIR);
+
+ String doc = output.getText(MOCK_DIR + "/" + base +
"/JavaObjectCloneInheritDocChild.html");
+ String cloneSection = findMethodSection(doc, "clone", "");
+ assertNotNull("Expected JavaObjectCloneInheritDocChild.html in
output", doc);
+ assertNotNull("Expected clone() section in:\n" + doc, cloneSection);
+ assertTrue("Expected inherited clone() text from java.lang.Object
in:\n" + doc,
+ normalizeWhitespace(cloneSection).contains("Creates and
returns a copy of this object"));
+ assertFalse("External Object inheritDoc should not remain literal
in:\n" + doc,
+ doc.contains("{@inheritDoc}"));
+ }
+
+ public void testExternalGroovyClassDocUsesActualSuperclassSemantics() {
+ assertNull("java.lang.Object should not invent a superclass",
+ new ExternalGroovyClassDoc(Object.class).superclass());
+ assertNull("Interfaces should not invent Object as a superclass",
+ new ExternalGroovyClassDoc(Map.class).superclass());
+ assertEquals("Concrete external classes should expose their reflected
superclass",
+ "java.lang.Object",
+ new
ExternalGroovyClassDoc(java.io.Writer.class).superclass().qualifiedTypeName());
+ }
+
+ public void
testExternalJavadocSupportStandaloneLookupDoesNotRetainCaches() {
+ ExternalJavadocSupport.clearCaches();
+
+ GroovyMethodDoc[] docs = ExternalJavadocSupport.methodsFor(new
ExternalGroovyClassDoc(Map.class));
+ assertTrue("Expected external methods for java.util.Map", docs.length
> 0);
+
+ ExternalJavadocSupport.CacheStats stats =
ExternalJavadocSupport.cacheStats();
+ assertEquals("Standalone external lookup should not retain raw comment
cache entries", 0, stats.rawCommentCacheSize());
+ assertEquals("Standalone external lookup should not retain method
metadata cache entries", 0, stats.methodCacheSize());
+ assertEquals("Standalone external lookup should not retain method doc
cache entries", 0, stats.methodDocCacheSize());
+ }
+
+ public void testExternalJavadocSupportClearsCachesWhenSessionCloses()
throws Exception {
+ ExternalJavadocSupport.clearCaches();
+
+ try (AutoCloseable ignored =
ExternalJavadocSupport.openCacheSession()) {
+ GroovyMethodDoc[] docs = ExternalJavadocSupport.methodsFor(new
ExternalGroovyClassDoc(Map.class));
+ assertTrue("Expected external methods for java.util.Map",
docs.length > 0);
+
+ ExternalJavadocSupport.CacheStats stats =
ExternalJavadocSupport.cacheStats();
+ assertTrue("Expected raw comment cache entries while the session
is active", stats.rawCommentCacheSize() > 0);
+ assertTrue("Expected method metadata cache entries while the
session is active", stats.methodCacheSize() > 0);
+ assertTrue("Expected method doc cache entries while the session is
active", stats.methodDocCacheSize() > 0);
+ }
+
+ ExternalJavadocSupport.CacheStats stats =
ExternalJavadocSupport.cacheStats();
+ assertEquals("Raw comment cache should be cleared after the last
session closes", 0, stats.rawCommentCacheSize());
+ assertEquals("Method metadata cache should be cleared after the last
session closes", 0, stats.methodCacheSize());
+ assertEquals("Method doc cache should be cleared after the last
session closes", 0, stats.methodDocCacheSize());
+ }
+
+ public void testInheritDocResolvesFromExternalMapAndObjectMethodsInHtml()
throws Exception {
+ String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
+ htmlTool.add(List.of(base + "/JavaImplementsMapInheritDoc.java"));
+
+ MockOutputTool output = new MockOutputTool();
+ htmlTool.renderToOutput(output, MOCK_DIR);
+
+ String doc = output.getText(MOCK_DIR + "/" + base +
"/JavaImplementsMapInheritDoc.html");
+ String clearSection = findMethodSection(doc, "clear", "");
+ String containsValueSection = findMethodSection(doc, "containsValue",
"java.lang.Object");
+ String equalsSection = findMethodSection(doc, "equals",
"java.lang.Object");
+ String hashCodeSection = findMethodSection(doc, "hashCode", "");
+ assertNotNull("Expected JavaImplementsMapInheritDoc.html in output",
doc);
+ assertNotNull("Expected clear() section in:\n" + doc, clearSection);
+ assertNotNull("Expected containsValue(Object) section in:\n" + doc,
containsValueSection);
+ assertNotNull("Expected equals(Object) section in:\n" + doc,
equalsSection);
+ assertNotNull("Expected hashCode() section in:\n" + doc,
hashCodeSection);
+ assertTrue("Expected inherited clear() text from java.util.Map in:\n"
+ doc,
+ normalizeWhitespace(clearSection).contains("Removes all of the
mappings from this map"));
+ assertTrue("Expected inherited containsValue(Object) text from
java.util.Map in:\n" + doc,
+ containsValueSection.contains("Returns <CODE>true</CODE> if
this map maps one or more keys to the"));
+ assertTrue("Expected inherited equals(Object) text from
java.lang.Object in:\n" + doc,
+ equalsSection.contains("Indicates whether some other object is
\"equal to\" this one"));
+ assertTrue("Expected normalized inherited hashCode() text from
java.lang.Object in:\n" + doc,
+ normalizeWhitespace(hashCodeSection).contains("a hash code
value for this object"));
+ assertFalse("Inherited external docs should not retain raw Javadoc
comment markers in:\n" + doc,
+ normalizeWhitespace(doc).contains("* Removes all of the
mappings"));
+ assertFalse("Inherited external docs should not leave raw link/index
inline tags in:\n" + doc,
+ doc.contains("{@linkplain") || doc.contains("{@index"));
+ assertFalse("External Map/Object inheritDoc should not remain literal
in:\n" + doc,
+ doc.contains("{@inheritDoc}"));
+ }
+
+ public void testNestedInternalClassReferencesResolveUsingDocPathNaming()
throws Exception {
+ String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
+ htmlTool.add(List.of(
+ base + "/JavaNestedResolutionOuter.java",
+ base + "/JavaNestedResolutionSamePackageConsumer.java",
+ base + "/sub/JavaNestedResolutionImportedConsumer.java"));
+
+ MockOutputTool output = new MockOutputTool();
+ htmlTool.renderToOutput(output, MOCK_DIR);
+
+ String samePackageDoc = output.getText(MOCK_DIR + "/" + base +
"/JavaNestedResolutionSamePackageConsumer.html");
+ String importedDoc = output.getText(MOCK_DIR + "/" + base +
"/sub/JavaNestedResolutionImportedConsumer.html");
+ String nestedConsumerDoc = output.getText(MOCK_DIR + "/" + base +
"/JavaNestedResolutionOuter/Enclosing.Consumer.html");
+ assertNotNull("Expected JavaNestedResolutionSamePackageConsumer.html
in output", samePackageDoc);
+ assertNotNull("Expected JavaNestedResolutionImportedConsumer.html in
output", importedDoc);
+ assertNotNull("Expected
JavaNestedResolutionOuter/Enclosing.Consumer.html in output",
nestedConsumerDoc);
+ assertNotNull("Expected same-package nested helper page in output",
+ output.getText(MOCK_DIR + "/" + base +
"/JavaNestedResolutionOuter.SamePackageHelper.html"));
+ assertNotNull("Expected imported nested helper page in output",
+ output.getText(MOCK_DIR + "/" + base +
"/JavaNestedResolutionOuter.ImportedHelper.html"));
+ assertNotNull("Expected sibling nested helper page in output",
+ output.getText(MOCK_DIR + "/" + base +
"/JavaNestedResolutionOuter/Enclosing.Sibling.html"));
+
+ assertTrue("Same-package nested type should link using dotted doc path
in:\n" + samePackageDoc,
+
samePackageDoc.contains("JavaNestedResolutionOuter.SamePackageHelper.html"));
+ assertFalse("Same-package nested type should not use binary-name or
slash lookup in:\n" + samePackageDoc,
+
samePackageDoc.contains("JavaNestedResolutionOuter$SamePackageHelper.html")
+ ||
samePackageDoc.contains("JavaNestedResolutionOuter/SamePackageHelper.html"));
+
+ assertTrue("Imported nested type should link using dotted doc path
in:\n" + importedDoc,
+
importedDoc.contains("JavaNestedResolutionOuter.ImportedHelper.html"));
+ assertFalse("Imported nested type should not use binary-name or slash
lookup in:\n" + importedDoc,
+
importedDoc.contains("JavaNestedResolutionOuter$ImportedHelper.html")
+ ||
importedDoc.contains("JavaNestedResolutionOuter/ImportedHelper.html"));
+
+ assertTrue("Nested sibling type should resolve through enclosing types
using dotted doc path in:\n" + nestedConsumerDoc,
+ nestedConsumerDoc.contains("Enclosing.Sibling.html"));
+ assertFalse("Nested sibling type should not use binary-name lookup
in:\n" + nestedConsumerDoc,
+ nestedConsumerDoc.contains("Enclosing$Sibling.html"));
+ }
+
// Cyclic inheritDoc references must collapse safely instead of
// recursing until the renderer overflows the stack.
public void testInheritDocCycleDoesNotOverflow() throws Exception {
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaExtendsWriterInheritDoc.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaExtendsWriterInheritDoc.java
new file mode 100644
index 0000000000..50e7d03f06
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaExtendsWriterInheritDoc.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles;
+
+import java.io.IOException;
+import java.io.Writer;
+
+public class JavaExtendsWriterInheritDoc extends Writer {
+ /** {@inheritDoc} */
+ @Override
+ public void write(char[] cbuf, int off, int len) {
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void flush() {
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void close() throws IOException {
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaImplementsMapInheritDoc.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaImplementsMapInheritDoc.java
new file mode 100644
index 0000000000..5caf396250
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaImplementsMapInheritDoc.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class JavaImplementsMapInheritDoc implements Map<String, Object> {
+ private final Map<String, Object> delegate = new LinkedHashMap<String,
Object>();
+
+ /** {@inheritDoc} */
+ @Override
+ public int size() {
+ return delegate.size();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isEmpty() {
+ return delegate.isEmpty();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean containsKey(Object key) {
+ return delegate.containsKey(key);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean containsValue(Object value) {
+ return delegate.containsValue(value);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object get(Object key) {
+ return delegate.get(key);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object put(String key, Object value) {
+ return delegate.put(key, value);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Object remove(Object key) {
+ return delegate.remove(key);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void putAll(Map<? extends String, ? extends Object> m) {
+ delegate.putAll(m);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void clear() {
+ delegate.clear();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Set<String> keySet() {
+ return delegate.keySet();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Collection<Object> values() {
+ return delegate.values();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Set<Entry<String, Object>> entrySet() {
+ return delegate.entrySet();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean equals(Object obj) {
+ return delegate.equals(obj);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public int hashCode() {
+ return delegate.hashCode();
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionOuter.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionOuter.java
new file mode 100644
index 0000000000..6423b1edf3
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionOuter.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles;
+
+public class JavaNestedResolutionOuter {
+ /** Same-package nested target. */
+ public static class SamePackageHelper {
+ }
+
+ /** Explicit-import nested target. */
+ public static class ImportedHelper {
+ }
+
+ /** Enclosing nested owner used to resolve sibling nested types. */
+ public static class Enclosing {
+ /** Nested sibling target. */
+ public static class Sibling {
+ }
+
+ /** Nested consumer that references a sibling by its simple source
name. */
+ public static class Consumer {
+ /**
+ * Returns the sibling helper type.
+ *
+ * @return the sibling helper type
+ */
+ public Sibling sibling() {
+ return null;
+ }
+ }
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionSamePackageConsumer.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionSamePackageConsumer.java
new file mode 100644
index 0000000000..b2622a0490
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaNestedResolutionSamePackageConsumer.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles;
+
+public class JavaNestedResolutionSamePackageConsumer {
+ /**
+ * Returns the same-package nested helper type.
+ *
+ * @return the same-package nested helper type
+ */
+ public JavaNestedResolutionOuter.SamePackageHelper helper() {
+ return null;
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaObjectCloneInheritDocChild.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaObjectCloneInheritDocChild.java
new file mode 100644
index 0000000000..9efd848e99
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/JavaObjectCloneInheritDocChild.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles;
+
+public class JavaObjectCloneInheritDocChild implements Cloneable {
+ /** {@inheritDoc} */
+ @Override
+ public JavaObjectCloneInheritDocChild clone() throws
CloneNotSupportedException {
+ return (JavaObjectCloneInheritDocChild) super.clone();
+ }
+}
diff --git
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/sub/JavaNestedResolutionImportedConsumer.java
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/sub/JavaNestedResolutionImportedConsumer.java
new file mode 100644
index 0000000000..8e86de5aa1
--- /dev/null
+++
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/testfiles/sub/JavaNestedResolutionImportedConsumer.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.codehaus.groovy.tools.groovydoc.testfiles.sub;
+
+import org.codehaus.groovy.tools.groovydoc.testfiles.JavaNestedResolutionOuter;
+
+public class JavaNestedResolutionImportedConsumer {
+ /**
+ * Returns the imported nested helper type.
+ *
+ * @return the imported nested helper type
+ */
+ public JavaNestedResolutionOuter.ImportedHelper helper() {
+ return null;
+ }
+}