Skip to content

Commit 17e66a2

Browse files
committed
GROOVY-11988: Add support for {@inheritdoc} in external JDK classes (address AI review comments)
1 parent f65c867 commit 17e66a2

3 files changed

Lines changed: 198 additions & 22 deletions

File tree

subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/ExternalJavadocSupport.java

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import java.util.Optional;
4646
import java.util.Set;
4747
import java.util.concurrent.ConcurrentHashMap;
48+
import java.util.concurrent.atomic.AtomicBoolean;
4849
import java.util.concurrent.atomic.AtomicInteger;
4950
import java.util.zip.ZipEntry;
5051
import java.util.zip.ZipFile;
@@ -55,6 +56,7 @@
5556
* source method overrides a method declared outside the documented source set.
5657
*/
5758
final class ExternalJavadocSupport {
59+
private static final System.Logger LOGGER = System.getLogger(ExternalJavadocSupport.class.getName());
5860
private static final JavaParser JAVA_PARSER = new JavaParser(
5961
new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)
6062
);
@@ -63,7 +65,10 @@ final class ExternalJavadocSupport {
6365
private static final Map<Class<?>, List<ExternalMethodData>> METHOD_CACHE = new ConcurrentHashMap<>();
6466
private static final Map<Class<?>, GroovyMethodDoc[]> METHOD_DOC_CACHE = new ConcurrentHashMap<>();
6567
private static final AtomicInteger ACTIVE_CACHE_SESSIONS = new AtomicInteger();
68+
private static final AtomicBoolean SRC_ZIP_WARNING_LOGGED = new AtomicBoolean();
69+
private static final Object ZIP_LOCK = new Object();
6670
private static final GroovyMethodDoc[] EMPTY_GROOVYMETHODDOC_ARRAY = new GroovyMethodDoc[0];
71+
private static ZipFile sharedZipFile;
6772

6873
private ExternalJavadocSupport() {
6974
}
@@ -115,14 +120,16 @@ static CacheStats cacheStats() {
115120
}
116121

117122
/**
118-
* Clears all external Javadoc caches. This method is automatically called when
119-
* the last active {@link CacheSession} is closed. It can also be called manually
120-
* to force a reset of cached data.
123+
* Clears all external Javadoc caches and releases the shared JDK source archive
124+
* handle. This method is automatically called when the last active
125+
* {@link CacheSession} is closed. It can also be called manually to force a reset
126+
* of cached data.
121127
*/
122128
static void clearCaches() {
123129
RAW_COMMENT_CACHE.clear();
124130
METHOD_CACHE.clear();
125131
METHOD_DOC_CACHE.clear();
132+
closeSharedZipFile();
126133
}
127134

128135
private static GroovyMethodDoc[] cachedMethodDocsFor(Class<?> externalClass) {
@@ -277,11 +284,26 @@ private static String resolveEffectiveComment(Class<?> ownerClass, Method method
277284
}
278285

279286
private static Optional<CompilationUnit> loadCompilationUnit(Class<?> externalClass) {
280-
if (JDK_SRC_ZIP == null) return Optional.empty();
287+
if (JDK_SRC_ZIP == null) {
288+
warnMissingSrcZipOnce();
289+
return Optional.empty();
290+
}
281291
String entryName = sourceEntryName(externalClass);
282292
if (entryName == null) return Optional.empty();
283293

294+
ZipFile shared = sharedZipFileForActiveSession();
295+
if (shared != null) {
296+
return parseCompilationUnit(shared, entryName);
297+
}
284298
try (ZipFile zip = new ZipFile(JDK_SRC_ZIP.toFile())) {
299+
return parseCompilationUnit(zip, entryName);
300+
} catch (IOException e) {
301+
throw new UncheckedIOException(e);
302+
}
303+
}
304+
305+
private static Optional<CompilationUnit> parseCompilationUnit(ZipFile zip, String entryName) {
306+
try {
285307
ZipEntry entry = zip.getEntry(entryName);
286308
if (entry == null) {
287309
entry = findFallbackEntry(zip, entryName);
@@ -297,7 +319,40 @@ private static Optional<CompilationUnit> loadCompilationUnit(Class<?> externalCl
297319
}
298320
}
299321

300-
private static ZipEntry findFallbackEntry(ZipFile zip, String entryName) {
322+
private static ZipFile sharedZipFileForActiveSession() {
323+
if (JDK_SRC_ZIP == null || ACTIVE_CACHE_SESSIONS.get() == 0) return null;
324+
synchronized (ZIP_LOCK) {
325+
if (sharedZipFile != null) return sharedZipFile;
326+
if (ACTIVE_CACHE_SESSIONS.get() == 0) return null;
327+
try {
328+
sharedZipFile = new ZipFile(JDK_SRC_ZIP.toFile());
329+
} catch (IOException e) {
330+
throw new UncheckedIOException(e);
331+
}
332+
return sharedZipFile;
333+
}
334+
}
335+
336+
private static void closeSharedZipFile() {
337+
synchronized (ZIP_LOCK) {
338+
if (sharedZipFile == null) return;
339+
try {
340+
sharedZipFile.close();
341+
} catch (IOException ignored) {
342+
}
343+
sharedZipFile = null;
344+
}
345+
}
346+
347+
private static void warnMissingSrcZipOnce() {
348+
if (SRC_ZIP_WARNING_LOGGED.compareAndSet(false, true)) {
349+
LOGGER.log(System.Logger.Level.WARNING,
350+
"JDK src.zip not found under java.home=" + System.getProperty("java.home")
351+
+ "; external {@inheritDoc} resolution from JDK classes is unavailable.");
352+
}
353+
}
354+
355+
static ZipEntry findFallbackEntry(ZipFile zip, String entryName) {
301356
int slash = entryName.indexOf('/');
302357
String suffix = slash >= 0 ? "/" + entryName.substring(slash + 1) : "/" + entryName;
303358
return zip.stream()
@@ -395,7 +450,7 @@ private static boolean matchesTypeName(String declaredType, Class<?> reflectedTy
395450
return false;
396451
}
397452

398-
private static String eraseTypeName(String declaredType) {
453+
static String eraseTypeName(String declaredType) {
399454
if (declaredType == null || declaredType.isEmpty()) return "";
400455
StringBuilder erased = new StringBuilder(declaredType.length());
401456
int genericDepth = 0;

subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,12 @@ private static boolean isReferenceType(String typeName) {
11561156
}
11571157

11581158
private static boolean isTypeVariableName(String typeName) {
1159+
// Heuristic for recognizing a Java type-variable name when matching an override
1160+
// against its parent declaration during {@inheritDoc} resolution. Matches the
1161+
// common single-letter (optionally numbered) convention (T, E, R, K, V, T1, ...).
1162+
// KEY and VALUE are accepted because Groovy's own org.apache.groovy.json.internal.Cache
1163+
// declares its type parameters as <KEY, VALUE>, which would otherwise look like
1164+
// real class names to this matcher.
11591165
return typeName.matches("[A-Z][0-9]?") || "KEY".equals(typeName) || "VALUE".equals(typeName);
11601166
}
11611167

subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java

Lines changed: 131 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
import java.util.regex.Pattern;
4848
import java.util.stream.Collectors;
4949
import java.util.stream.Stream;
50+
import java.util.zip.ZipEntry;
51+
import java.util.zip.ZipFile;
52+
import java.util.zip.ZipOutputStream;
5053

5154
public class GroovyDocToolTest extends GroovyTestCase {
5255
private static final String MOCK_DIR = "mock/doc";
@@ -824,6 +827,7 @@ public void testInheritDocResolvesForInterfaceMethodsInheritedThroughSuperclasse
824827
}
825828

826829
public void testInheritDocResolvesFromExternalJdkAbstractClassInHtml() throws Exception {
830+
if (skipIfNoJdkSrcZip()) return;
827831
String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
828832
htmlTool.add(List.of(base + "/JavaExtendsWriterInheritDoc.java"));
829833

@@ -836,15 +840,16 @@ public void testInheritDocResolvesFromExternalJdkAbstractClassInHtml() throws Ex
836840
assertNotNull("Expected JavaExtendsWriterInheritDoc.html in output", doc);
837841
assertNotNull("Expected close() section in:\n" + doc, closeSection);
838842
assertNotNull("Expected flush() section in:\n" + doc, flushSection);
839-
assertTrue("Expected inherited close() text from java.io.Writer in:\n" + doc,
840-
normalizeWhitespace(closeSection).contains("Closes the stream"));
841-
assertTrue("Expected inherited flush() text from java.io.Writer in:\n" + doc,
842-
normalizeWhitespace(flushSection).contains("Flushes the stream"));
843+
assertMatches("Expected inherited close() text from java.io.Writer in:\n" + doc,
844+
"(?i)close[sd]?\\b[^\\n]{0,40}\\bstream", normalizeWhitespace(closeSection));
845+
assertMatches("Expected inherited flush() text from java.io.Writer in:\n" + doc,
846+
"(?i)flush(es|ed|ing)?\\b[^\\n]{0,40}\\bstream", normalizeWhitespace(flushSection));
843847
assertFalse("External JDK inheritDoc should not remain literal in:\n" + doc,
844848
doc.contains("{@inheritDoc}"));
845849
}
846850

847851
public void testInheritDocResolvesFromExternalObjectMethodInHtml() throws Exception {
852+
if (skipIfNoJdkSrcZip()) return;
848853
String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
849854
htmlTool.add(List.of(base + "/JavaObjectCloneInheritDocChild.java"));
850855

@@ -855,8 +860,8 @@ public void testInheritDocResolvesFromExternalObjectMethodInHtml() throws Except
855860
String cloneSection = findMethodSection(doc, "clone", "");
856861
assertNotNull("Expected JavaObjectCloneInheritDocChild.html in output", doc);
857862
assertNotNull("Expected clone() section in:\n" + doc, cloneSection);
858-
assertTrue("Expected inherited clone() text from java.lang.Object in:\n" + doc,
859-
normalizeWhitespace(cloneSection).contains("Creates and returns a copy of this object"));
863+
assertMatches("Expected inherited clone() text from java.lang.Object in:\n" + doc,
864+
"(?i)copy\\s+of\\s+this\\s+object", normalizeWhitespace(cloneSection));
860865
assertFalse("External Object inheritDoc should not remain literal in:\n" + doc,
861866
doc.contains("{@inheritDoc}"));
862867
}
@@ -903,6 +908,7 @@ public void testExternalJavadocSupportClearsCachesWhenSessionCloses() throws Exc
903908
}
904909

905910
public void testInheritDocResolvesFromExternalMapAndObjectMethodsInHtml() throws Exception {
911+
if (skipIfNoJdkSrcZip()) return;
906912
String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
907913
htmlTool.add(List.of(base + "/JavaImplementsMapInheritDoc.java"));
908914

@@ -919,16 +925,16 @@ public void testInheritDocResolvesFromExternalMapAndObjectMethodsInHtml() throws
919925
assertNotNull("Expected containsValue(Object) section in:\n" + doc, containsValueSection);
920926
assertNotNull("Expected equals(Object) section in:\n" + doc, equalsSection);
921927
assertNotNull("Expected hashCode() section in:\n" + doc, hashCodeSection);
922-
assertTrue("Expected inherited clear() text from java.util.Map in:\n" + doc,
923-
normalizeWhitespace(clearSection).contains("Removes all of the mappings from this map"));
924-
assertTrue("Expected inherited containsValue(Object) text from java.util.Map in:\n" + doc,
925-
containsValueSection.contains("Returns <CODE>true</CODE> if this map maps one or more keys to the"));
926-
assertTrue("Expected inherited equals(Object) text from java.lang.Object in:\n" + doc,
927-
equalsSection.contains("Indicates whether some other object is \"equal to\" this one"));
928-
assertTrue("Expected normalized inherited hashCode() text from java.lang.Object in:\n" + doc,
929-
normalizeWhitespace(hashCodeSection).contains("a hash code value for this object"));
930-
assertFalse("Inherited external docs should not retain raw Javadoc comment markers in:\n" + doc,
931-
normalizeWhitespace(doc).contains("* Removes all of the mappings"));
928+
assertMatches("Expected inherited clear() text from java.util.Map in:\n" + doc,
929+
"(?i)remov(es|ing)?\\b[^\\n]{0,30}\\bmapping", normalizeWhitespace(clearSection));
930+
assertMatches("Expected inherited containsValue(Object) text from java.util.Map in:\n" + doc,
931+
"(?i)map\\s+maps?\\s+one\\s+or\\s+more\\s+key", normalizeWhitespace(containsValueSection));
932+
assertMatches("Expected inherited equals(Object) text from java.lang.Object in:\n" + doc,
933+
"(?i)\\bother\\s+object\\b[^\\n]{0,40}\\bequal", normalizeWhitespace(equalsSection));
934+
assertMatches("Expected normalized inherited hashCode() text from java.lang.Object in:\n" + doc,
935+
"(?i)hash\\s*code\\b[^\\n]{0,30}\\bobject", normalizeWhitespace(hashCodeSection));
936+
assertFalse("Inherited external docs should not retain raw Javadoc comment markers (a leading '* ') in:\n" + doc,
937+
Pattern.compile("\\*\\s+(?i:Removes|Returns|Indicates|Creates|Closes|Flushes)\\b").matcher(doc).find());
932938
assertFalse("Inherited external docs should not leave raw link/index inline tags in:\n" + doc,
933939
doc.contains("{@linkplain") || doc.contains("{@index"));
934940
assertFalse("External Map/Object inheritDoc should not remain literal in:\n" + doc,
@@ -1051,6 +1057,31 @@ private static String normalizeWhitespace(String text) {
10511057
return text == null ? null : text.replaceAll("\\s+", " ").trim();
10521058
}
10531059

1060+
private static void assertMatches(String message, String regex, String content) {
1061+
if (content == null || !Pattern.compile(regex).matcher(content).find()) {
1062+
fail(message + "\n pattern: " + regex + "\n content: " + content);
1063+
}
1064+
}
1065+
1066+
private boolean skipIfNoJdkSrcZip() {
1067+
if (jdkSrcZipPath() != null) return false;
1068+
System.out.println("[skip] " + getName() + ": JDK src.zip not found under java.home="
1069+
+ System.getProperty("java.home"));
1070+
return true;
1071+
}
1072+
1073+
private static Path jdkSrcZipPath() {
1074+
String javaHome = System.getProperty("java.home");
1075+
if (javaHome == null || javaHome.isEmpty()) return null;
1076+
Path home = Paths.get(javaHome);
1077+
Path direct = home.resolve("lib/src.zip");
1078+
if (Files.isRegularFile(direct)) return direct;
1079+
Path parent = home.getParent();
1080+
if (parent == null) return null;
1081+
Path sibling = parent.resolve("lib/src.zip");
1082+
return Files.isRegularFile(sibling) ? sibling : null;
1083+
}
1084+
10541085
private static GroovyMethodDoc findMethod(GroovyClassDoc classDoc, String name, int parameterCount) {
10551086
for (GroovyMethodDoc method : classDoc.methods()) {
10561087
if (name.equals(method.name()) && method.parameters().length == parameterCount) {
@@ -3448,4 +3479,88 @@ private GroovyClassDoc getGroovyClassDocByName(GroovyRootDoc root, String name)
34483479

34493480
return null;
34503481
}
3482+
3483+
public void testEraseTypeNameStripsGenericsAndWildcards() {
3484+
assertEquals("List", ExternalJavadocSupport.eraseTypeName("List<String>"));
3485+
assertEquals("Map", ExternalJavadocSupport.eraseTypeName("Map<String, Integer>"));
3486+
assertEquals("Map", ExternalJavadocSupport.eraseTypeName("Map<String, Map<String, Integer>>"));
3487+
assertEquals("Number", ExternalJavadocSupport.eraseTypeName("? extends Number"));
3488+
assertEquals("Integer", ExternalJavadocSupport.eraseTypeName("? super Integer"));
3489+
assertEquals("Object", ExternalJavadocSupport.eraseTypeName("?"));
3490+
assertEquals("List[]", ExternalJavadocSupport.eraseTypeName("List<? extends T>[]"));
3491+
assertEquals("", ExternalJavadocSupport.eraseTypeName(""));
3492+
assertEquals("", ExternalJavadocSupport.eraseTypeName(null));
3493+
}
3494+
3495+
public void testFindFallbackEntryFindsByTrailingPathSuffix() throws Exception {
3496+
Path zipPath = Files.createTempFile("ext-javadoc-fallback", ".zip");
3497+
try {
3498+
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zipPath))) {
3499+
out.putNextEntry(new ZipEntry("noise/elsewhere/Other.java"));
3500+
out.write("// other".getBytes());
3501+
out.closeEntry();
3502+
out.putNextEntry(new ZipEntry("some.module/com/example/Foo.java"));
3503+
out.write("// foo".getBytes());
3504+
out.closeEntry();
3505+
}
3506+
try (ZipFile zip = new ZipFile(zipPath.toFile())) {
3507+
ZipEntry hit = ExternalJavadocSupport.findFallbackEntry(zip, "java.base/com/example/Foo.java");
3508+
assertNotNull("Expected fallback to find Foo.java by trailing path", hit);
3509+
assertEquals("some.module/com/example/Foo.java", hit.getName());
3510+
3511+
ZipEntry miss = ExternalJavadocSupport.findFallbackEntry(zip, "java.base/com/example/Missing.java");
3512+
assertNull("No entry should match Missing.java", miss);
3513+
}
3514+
} finally {
3515+
Files.deleteIfExists(zipPath);
3516+
}
3517+
}
3518+
3519+
public void testFindFallbackEntryHandlesEntryNameWithoutSlash() throws Exception {
3520+
Path zipPath = Files.createTempFile("ext-javadoc-fallback-noslash", ".zip");
3521+
try {
3522+
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zipPath))) {
3523+
out.putNextEntry(new ZipEntry("any/where/Bare.java"));
3524+
out.write("// bare".getBytes());
3525+
out.closeEntry();
3526+
}
3527+
try (ZipFile zip = new ZipFile(zipPath.toFile())) {
3528+
ZipEntry hit = ExternalJavadocSupport.findFallbackEntry(zip, "Bare.java");
3529+
assertNotNull("Expected fallback to find entry by '/Bare.java' suffix", hit);
3530+
assertEquals("any/where/Bare.java", hit.getName());
3531+
}
3532+
} finally {
3533+
Files.deleteIfExists(zipPath);
3534+
}
3535+
}
3536+
3537+
// Exercises the matchesTypeName same-package / java.lang / array-aware branches: the JDK
3538+
// source for java.lang.Throwable declares getMessage() returning String, setStackTrace
3539+
// taking StackTraceElement[] (bare same-package types). Override-matching has to fall
3540+
// through past canonical/typeName checks before resolving these to the reflected
3541+
// java.lang.* qualified types and producing a non-empty raw comment.
3542+
public void testMatchesTypeNameResolvesUnqualifiedJavaLangTypes() {
3543+
if (skipIfNoJdkSrcZip()) return;
3544+
try (AutoCloseable ignored = ExternalJavadocSupport.openCacheSession()) {
3545+
GroovyMethodDoc[] docs = ExternalJavadocSupport.methodsFor(new ExternalGroovyClassDoc(Throwable.class));
3546+
assertTrue("Expected reflective methods for java.lang.Throwable", docs.length > 0);
3547+
3548+
boolean foundGetMessage = false;
3549+
boolean foundSetStackTrace = false;
3550+
for (GroovyMethodDoc doc : docs) {
3551+
String raw = ((SimpleGroovyMethodDoc) doc).getRawCommentText();
3552+
if (raw == null || raw.isEmpty()) continue;
3553+
if ("getMessage".equals(doc.name()) && doc.parameters().length == 0) {
3554+
foundGetMessage = true;
3555+
} else if ("setStackTrace".equals(doc.name()) && doc.parameters().length == 1) {
3556+
foundSetStackTrace = true;
3557+
}
3558+
}
3559+
assertTrue("Expected resolved Javadoc for Throwable#getMessage()", foundGetMessage);
3560+
assertTrue("Expected resolved Javadoc for Throwable#setStackTrace(StackTraceElement[])"
3561+
+ " (bare same-package array parameter)", foundSetStackTrace);
3562+
} catch (Exception e) {
3563+
fail("session close threw: " + e);
3564+
}
3565+
}
34513566
}

0 commit comments

Comments
 (0)