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 cbdbd3ef1be..577670cd359 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.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 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(); } @@ -75,7 +76,8 @@ public GroovyAnnotationRef[] annotations() { */ @Override public String qualifiedTypeName() { - return externalClass.getName(); + String canonicalName = externalClass.getCanonicalName(); + return canonicalName != null ? canonicalName : externalClass.getName(); } /** @@ -83,15 +85,14 @@ public String qualifiedTypeName() { */ @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 String getTypeSourceDescription() { */ @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 GroovyClassDoc[] innerClasses(boolean filter) { */ @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 GroovyType[] interfaceTypes() { */ @Override public boolean isAbstract() { - return false; + return Modifier.isAbstract(externalClass.getModifiers()); } /** @@ -280,7 +292,7 @@ public boolean isExternalizable() { */ @Override public boolean isSerializable() { - return false; + return java.io.Serializable.class.isAssignableFrom(externalClass); } /** @@ -288,7 +300,7 @@ public boolean isSerializable() { */ @Override public GroovyMethodDoc[] methods() { - return EMPTY_GROOVYMETHODDOC_ARRAY; + return ExternalJavadocSupport.methodsFor(this); } /** @@ -296,7 +308,7 @@ public GroovyMethodDoc[] methods() { */ @Override public GroovyMethodDoc[] methods(boolean filter) { - return EMPTY_GROOVYMETHODDOC_ARRAY; + return methods(); } /** @@ -360,7 +372,7 @@ public GroovyPackageDoc containingPackage() { */ @Override public boolean isFinal() { - return false; + return Modifier.isFinal(externalClass.getModifiers()); } /** @@ -376,7 +388,7 @@ public boolean isPackagePrivate() { */ @Override public boolean isPrivate() { - return false; + return Modifier.isPrivate(externalClass.getModifiers()); } /** @@ -384,7 +396,7 @@ public boolean isPrivate() { */ @Override public boolean isProtected() { - return false; + return Modifier.isProtected(externalClass.getModifiers()); } /** @@ -392,7 +404,7 @@ public boolean isProtected() { */ @Override public boolean isPublic() { - return false; + return Modifier.isPublic(externalClass.getModifiers()); } /** @@ -400,7 +412,7 @@ public boolean isPublic() { */ @Override public boolean isStatic() { - return false; + return Modifier.isStatic(externalClass.getModifiers()); } /** @@ -416,7 +428,7 @@ public String modifiers() { */ @Override public int modifierSpecifier() { - return 0; + return externalClass.getModifiers(); } /** @@ -424,7 +436,7 @@ public int modifierSpecifier() { */ @Override public String qualifiedName() { - return null; + return externalClass.getName(); } /** @@ -448,7 +460,7 @@ public String getRawCommentText() { */ @Override public boolean isAnnotationType() { - return false; + return externalClass.isAnnotation(); } /** @@ -464,7 +476,7 @@ public boolean isAnnotationTypeElement() { */ @Override public boolean isClass() { - return false; + return !externalClass.isInterface() && !externalClass.isAnnotation() && !externalClass.isEnum() && !externalClass.isRecord(); } /** @@ -488,7 +500,7 @@ public boolean isDeprecated() { */ @Override public boolean isEnum() { - return false; + return externalClass.isEnum(); } /** @@ -496,7 +508,7 @@ public boolean isEnum() { */ @Override public boolean isRecord() { - return false; + return externalClass.isRecord(); } /** @@ -544,7 +556,7 @@ public boolean isIncluded() { */ @Override public boolean isInterface() { - return false; + return externalClass.isInterface(); } /** @@ -560,7 +572,7 @@ public boolean isMethod() { */ @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 00000000000..bd6f13716eb --- /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, Map> RAW_COMMENT_CACHE = new ConcurrentHashMap<>(); + private static final Map, List> METHOD_CACHE = new ConcurrentHashMap<>(); + private static final Map, 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. + * + *

This method should be called at the start of a batch groovydoc rendering + * operation that will perform multiple external inheritDoc lookups.

+ * + * @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 loadExternalMethods(Class externalClass) { + Method[] declaredMethods = externalClass.getDeclaredMethods(); + Arrays.sort(declaredMethods, Comparator.comparing(Method::getName) + .thenComparingInt(Method::getParameterCount) + .thenComparing(Method::toGenericString)); + + List 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 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 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. + * + *

This class is not intended for public use; obtain instances via + * {@link ExternalJavadocSupport#openCacheSession()}.

+ */ + 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. + * + *

This class is immutable and used for diagnostics and testing.

+ */ + 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 parseMethodComments(Class externalClass) { + Map comments = new LinkedHashMap<>(); + Optional source = loadCompilationUnit(externalClass); + if (source.isEmpty()) return comments; + + Optional> 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 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 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 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> 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 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> 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> 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 parameterTypeNames(Method method) { + Class[] parameterTypes = method.getParameterTypes(); + List 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. + * + *

The raw comment text may contain {@code {@inheritDoc}} markers that are + * expanded during cache construction.

+ */ + private static final class ExternalMethodData { + private final String name; + private final String returnTypeName; + private final List parameterTypeNames; + private final String rawCommentText; + + private ExternalMethodData(String name, String returnTypeName, List 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 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 dcb8bd9192a..ded95f72532 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 void renderToOutput(OutputTool output, String destdir) throws Exception { 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 7a9cfd5ac55..9fdd2a51bca 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 @@ private GroovyClassDoc doResolveClass(final GroovyRootDoc rootDoc, final String 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 @@ private static boolean isPrimitiveType(String name) { 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 @@ private GroovyClassDoc resolveInternalClassDocFromImport(GroovyRootDoc rootDoc, 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 f8d7384ee4b..b3098a00bd4 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 @@ private static String dedent(String body) { 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 @@ private static void renderInlineTag(String name, String body, StringBuilder out, // 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 @@ private static void renderInlineTag(String name, String body, StringBuilder out, 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 @@ private static void renderInlineTag(String name, String body, StringBuilder out, 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 @@ private static void renderInlineTag(String name, String body, StringBuilder out, * 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. * *

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 @@ private static String resolveInheritDoc(GroovyMemberDoc memberDoc, Set 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 @@ private static GroovyMethodDoc findInheritedInterfaceMethod(GroovyMethodDoc this 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 9a1e936da01..a396ba9aba1 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 void testInheritDocResolvesForInterfaceMethodsInheritedThroughSuperclasse 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 true 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 00000000000..50e7d03f06e --- /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 00000000000..5caf3962508 --- /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 { + private final Map delegate = new LinkedHashMap(); + + /** {@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 m) { + delegate.putAll(m); + } + + /** {@inheritDoc} */ + @Override + public void clear() { + delegate.clear(); + } + + /** {@inheritDoc} */ + @Override + public Set keySet() { + return delegate.keySet(); + } + + /** {@inheritDoc} */ + @Override + public Collection values() { + return delegate.values(); + } + + /** {@inheritDoc} */ + @Override + public Set> 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 00000000000..6423b1edf3b --- /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 00000000000..b2622a0490a --- /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 00000000000..9efd848e998 --- /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 00000000000..8e86de5aa10 --- /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; + } +}