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 17e66a2aab GROOVY-11988: Add support for {@inheritDoc} in external JDK 
classes (address AI review comments)
17e66a2aab is described below

commit 17e66a2aab7d839e7e81075a52a81d73a5034d97
Author: Paul King <[email protected]>
AuthorDate: Fri May 8 21:12:22 2026 +1000

    GROOVY-11988: Add support for {@inheritDoc} in external JDK classes 
(address AI review comments)
---
 .../tools/groovydoc/ExternalJavadocSupport.java    |  67 +++++++++-
 .../groovy/tools/groovydoc/TagRenderer.java        |   6 +
 .../groovy/tools/groovydoc/GroovyDocToolTest.java  | 147 ++++++++++++++++++---
 3 files changed, 198 insertions(+), 22 deletions(-)

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
index bd6f13716e..47a5838818 100644
--- 
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
@@ -45,6 +45,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -55,6 +56,7 @@ import java.util.zip.ZipFile;
  * source method overrides a method declared outside the documented source set.
  */
 final class ExternalJavadocSupport {
+    private static final System.Logger LOGGER = 
System.getLogger(ExternalJavadocSupport.class.getName());
     private static final JavaParser JAVA_PARSER = new JavaParser(
             new 
ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)
     );
@@ -63,7 +65,10 @@ final class ExternalJavadocSupport {
     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 AtomicBoolean SRC_ZIP_WARNING_LOGGED = new 
AtomicBoolean();
+    private static final Object ZIP_LOCK = new Object();
     private static final GroovyMethodDoc[] EMPTY_GROOVYMETHODDOC_ARRAY = new 
GroovyMethodDoc[0];
+    private static ZipFile sharedZipFile;
 
     private ExternalJavadocSupport() {
     }
@@ -115,14 +120,16 @@ final class ExternalJavadocSupport {
     }
 
     /**
-     * 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.
+     * Clears all external Javadoc caches and releases the shared JDK source 
archive
+     * handle. 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();
+        closeSharedZipFile();
     }
 
     private static GroovyMethodDoc[] cachedMethodDocsFor(Class<?> 
externalClass) {
@@ -277,11 +284,26 @@ final class ExternalJavadocSupport {
     }
 
     private static Optional<CompilationUnit> loadCompilationUnit(Class<?> 
externalClass) {
-        if (JDK_SRC_ZIP == null) return Optional.empty();
+        if (JDK_SRC_ZIP == null) {
+            warnMissingSrcZipOnce();
+            return Optional.empty();
+        }
         String entryName = sourceEntryName(externalClass);
         if (entryName == null) return Optional.empty();
 
+        ZipFile shared = sharedZipFileForActiveSession();
+        if (shared != null) {
+            return parseCompilationUnit(shared, entryName);
+        }
         try (ZipFile zip = new ZipFile(JDK_SRC_ZIP.toFile())) {
+            return parseCompilationUnit(zip, entryName);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private static Optional<CompilationUnit> parseCompilationUnit(ZipFile zip, 
String entryName) {
+        try {
             ZipEntry entry = zip.getEntry(entryName);
             if (entry == null) {
                 entry = findFallbackEntry(zip, entryName);
@@ -297,7 +319,40 @@ final class ExternalJavadocSupport {
         }
     }
 
-    private static ZipEntry findFallbackEntry(ZipFile zip, String entryName) {
+    private static ZipFile sharedZipFileForActiveSession() {
+        if (JDK_SRC_ZIP == null || ACTIVE_CACHE_SESSIONS.get() == 0) return 
null;
+        synchronized (ZIP_LOCK) {
+            if (sharedZipFile != null) return sharedZipFile;
+            if (ACTIVE_CACHE_SESSIONS.get() == 0) return null;
+            try {
+                sharedZipFile = new ZipFile(JDK_SRC_ZIP.toFile());
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+            return sharedZipFile;
+        }
+    }
+
+    private static void closeSharedZipFile() {
+        synchronized (ZIP_LOCK) {
+            if (sharedZipFile == null) return;
+            try {
+                sharedZipFile.close();
+            } catch (IOException ignored) {
+            }
+            sharedZipFile = null;
+        }
+    }
+
+    private static void warnMissingSrcZipOnce() {
+        if (SRC_ZIP_WARNING_LOGGED.compareAndSet(false, true)) {
+            LOGGER.log(System.Logger.Level.WARNING,
+                    "JDK src.zip not found under java.home=" + 
System.getProperty("java.home")
+                            + "; external {@inheritDoc} resolution from JDK 
classes is unavailable.");
+        }
+    }
+
+    static ZipEntry findFallbackEntry(ZipFile zip, String entryName) {
         int slash = entryName.indexOf('/');
         String suffix = slash >= 0 ? "/" + entryName.substring(slash + 1) : 
"/" + entryName;
         return zip.stream()
@@ -395,7 +450,7 @@ final class ExternalJavadocSupport {
         return false;
     }
 
-    private static String eraseTypeName(String declaredType) {
+    static String eraseTypeName(String declaredType) {
         if (declaredType == null || declaredType.isEmpty()) return "";
         StringBuilder erased = new StringBuilder(declaredType.length());
         int genericDepth = 0;
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 7fc8960192..8eaa1c59db 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
@@ -1156,6 +1156,12 @@ final class TagRenderer {
     }
 
     private static boolean isTypeVariableName(String typeName) {
+        // Heuristic for recognizing a Java type-variable name when matching 
an override
+        // against its parent declaration during {@inheritDoc} resolution. 
Matches the
+        // common single-letter (optionally numbered) convention (T, E, R, K, 
V, T1, ...).
+        // KEY and VALUE are accepted because Groovy's own 
org.apache.groovy.json.internal.Cache
+        // declares its type parameters as <KEY, VALUE>, which would otherwise 
look like
+        // real class names to this matcher.
         return typeName.matches("[A-Z][0-9]?") || "KEY".equals(typeName) || 
"VALUE".equals(typeName);
     }
 
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 8dd876411e..95f8d12ff2 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
@@ -47,6 +47,9 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipOutputStream;
 
 public class GroovyDocToolTest extends GroovyTestCase {
     private static final String MOCK_DIR = "mock/doc";
@@ -824,6 +827,7 @@ public class GroovyDocToolTest extends GroovyTestCase {
     }
 
     public void testInheritDocResolvesFromExternalJdkAbstractClassInHtml() 
throws Exception {
+        if (skipIfNoJdkSrcZip()) return;
         String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
         htmlTool.add(List.of(base + "/JavaExtendsWriterInheritDoc.java"));
 
@@ -836,15 +840,16 @@ public class GroovyDocToolTest extends GroovyTestCase {
         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"));
+        assertMatches("Expected inherited close() text from java.io.Writer 
in:\n" + doc,
+                "(?i)close[sd]?\\b[^\\n]{0,40}\\bstream", 
normalizeWhitespace(closeSection));
+        assertMatches("Expected inherited flush() text from java.io.Writer 
in:\n" + doc,
+                "(?i)flush(es|ed|ing)?\\b[^\\n]{0,40}\\bstream", 
normalizeWhitespace(flushSection));
         assertFalse("External JDK inheritDoc should not remain literal in:\n" 
+ doc,
                 doc.contains("{@inheritDoc}"));
     }
 
     public void testInheritDocResolvesFromExternalObjectMethodInHtml() throws 
Exception {
+        if (skipIfNoJdkSrcZip()) return;
         String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
         htmlTool.add(List.of(base + "/JavaObjectCloneInheritDocChild.java"));
 
@@ -855,8 +860,8 @@ public class GroovyDocToolTest extends GroovyTestCase {
         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"));
+        assertMatches("Expected inherited clone() text from java.lang.Object 
in:\n" + doc,
+                "(?i)copy\\s+of\\s+this\\s+object", 
normalizeWhitespace(cloneSection));
         assertFalse("External Object inheritDoc should not remain literal 
in:\n" + doc,
                 doc.contains("{@inheritDoc}"));
     }
@@ -903,6 +908,7 @@ public class GroovyDocToolTest extends GroovyTestCase {
     }
 
     public void testInheritDocResolvesFromExternalMapAndObjectMethodsInHtml() 
throws Exception {
+        if (skipIfNoJdkSrcZip()) return;
         String base = "org/codehaus/groovy/tools/groovydoc/testfiles";
         htmlTool.add(List.of(base + "/JavaImplementsMapInheritDoc.java"));
 
@@ -919,16 +925,16 @@ public class GroovyDocToolTest extends GroovyTestCase {
         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"));
+        assertMatches("Expected inherited clear() text from java.util.Map 
in:\n" + doc,
+                "(?i)remov(es|ing)?\\b[^\\n]{0,30}\\bmapping", 
normalizeWhitespace(clearSection));
+        assertMatches("Expected inherited containsValue(Object) text from 
java.util.Map in:\n" + doc,
+                "(?i)map\\s+maps?\\s+one\\s+or\\s+more\\s+key", 
normalizeWhitespace(containsValueSection));
+        assertMatches("Expected inherited equals(Object) text from 
java.lang.Object in:\n" + doc,
+                "(?i)\\bother\\s+object\\b[^\\n]{0,40}\\bequal", 
normalizeWhitespace(equalsSection));
+        assertMatches("Expected normalized inherited hashCode() text from 
java.lang.Object in:\n" + doc,
+                "(?i)hash\\s*code\\b[^\\n]{0,30}\\bobject", 
normalizeWhitespace(hashCodeSection));
+        assertFalse("Inherited external docs should not retain raw Javadoc 
comment markers (a leading '* ') in:\n" + doc,
+                
Pattern.compile("\\*\\s+(?i:Removes|Returns|Indicates|Creates|Closes|Flushes)\\b").matcher(doc).find());
         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,
@@ -1051,6 +1057,31 @@ public class GroovyDocToolTest extends GroovyTestCase {
         return text == null ? null : text.replaceAll("\\s+", " ").trim();
     }
 
+    private static void assertMatches(String message, String regex, String 
content) {
+        if (content == null || 
!Pattern.compile(regex).matcher(content).find()) {
+            fail(message + "\n  pattern: " + regex + "\n  content: " + 
content);
+        }
+    }
+
+    private boolean skipIfNoJdkSrcZip() {
+        if (jdkSrcZipPath() != null) return false;
+        System.out.println("[skip] " + getName() + ": JDK src.zip not found 
under java.home="
+                + System.getProperty("java.home"));
+        return true;
+    }
+
+    private static Path jdkSrcZipPath() {
+        String javaHome = System.getProperty("java.home");
+        if (javaHome == null || javaHome.isEmpty()) return null;
+        Path home = Paths.get(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;
+    }
+
     private static GroovyMethodDoc findMethod(GroovyClassDoc classDoc, String 
name, int parameterCount) {
         for (GroovyMethodDoc method : classDoc.methods()) {
             if (name.equals(method.name()) && method.parameters().length == 
parameterCount) {
@@ -3448,4 +3479,88 @@ public class GroovyDocToolTest extends GroovyTestCase {
 
         return null;
     }
+
+    public void testEraseTypeNameStripsGenericsAndWildcards() {
+        assertEquals("List", 
ExternalJavadocSupport.eraseTypeName("List<String>"));
+        assertEquals("Map", ExternalJavadocSupport.eraseTypeName("Map<String, 
Integer>"));
+        assertEquals("Map", ExternalJavadocSupport.eraseTypeName("Map<String, 
Map<String, Integer>>"));
+        assertEquals("Number", ExternalJavadocSupport.eraseTypeName("? extends 
Number"));
+        assertEquals("Integer", ExternalJavadocSupport.eraseTypeName("? super 
Integer"));
+        assertEquals("Object", ExternalJavadocSupport.eraseTypeName("?"));
+        assertEquals("List[]", ExternalJavadocSupport.eraseTypeName("List<? 
extends T>[]"));
+        assertEquals("", ExternalJavadocSupport.eraseTypeName(""));
+        assertEquals("", ExternalJavadocSupport.eraseTypeName(null));
+    }
+
+    public void testFindFallbackEntryFindsByTrailingPathSuffix() throws 
Exception {
+        Path zipPath = Files.createTempFile("ext-javadoc-fallback", ".zip");
+        try {
+            try (ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(zipPath))) {
+                out.putNextEntry(new ZipEntry("noise/elsewhere/Other.java"));
+                out.write("// other".getBytes());
+                out.closeEntry();
+                out.putNextEntry(new 
ZipEntry("some.module/com/example/Foo.java"));
+                out.write("// foo".getBytes());
+                out.closeEntry();
+            }
+            try (ZipFile zip = new ZipFile(zipPath.toFile())) {
+                ZipEntry hit = ExternalJavadocSupport.findFallbackEntry(zip, 
"java.base/com/example/Foo.java");
+                assertNotNull("Expected fallback to find Foo.java by trailing 
path", hit);
+                assertEquals("some.module/com/example/Foo.java", 
hit.getName());
+
+                ZipEntry miss = ExternalJavadocSupport.findFallbackEntry(zip, 
"java.base/com/example/Missing.java");
+                assertNull("No entry should match Missing.java", miss);
+            }
+        } finally {
+            Files.deleteIfExists(zipPath);
+        }
+    }
+
+    public void testFindFallbackEntryHandlesEntryNameWithoutSlash() throws 
Exception {
+        Path zipPath = Files.createTempFile("ext-javadoc-fallback-noslash", 
".zip");
+        try {
+            try (ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(zipPath))) {
+                out.putNextEntry(new ZipEntry("any/where/Bare.java"));
+                out.write("// bare".getBytes());
+                out.closeEntry();
+            }
+            try (ZipFile zip = new ZipFile(zipPath.toFile())) {
+                ZipEntry hit = ExternalJavadocSupport.findFallbackEntry(zip, 
"Bare.java");
+                assertNotNull("Expected fallback to find entry by '/Bare.java' 
suffix", hit);
+                assertEquals("any/where/Bare.java", hit.getName());
+            }
+        } finally {
+            Files.deleteIfExists(zipPath);
+        }
+    }
+
+    // Exercises the matchesTypeName same-package / java.lang / array-aware 
branches: the JDK
+    // source for java.lang.Throwable declares getMessage() returning String, 
setStackTrace
+    // taking StackTraceElement[] (bare same-package types). Override-matching 
has to fall
+    // through past canonical/typeName checks before resolving these to the 
reflected
+    // java.lang.* qualified types and producing a non-empty raw comment.
+    public void testMatchesTypeNameResolvesUnqualifiedJavaLangTypes() {
+        if (skipIfNoJdkSrcZip()) return;
+        try (AutoCloseable ignored = 
ExternalJavadocSupport.openCacheSession()) {
+            GroovyMethodDoc[] docs = ExternalJavadocSupport.methodsFor(new 
ExternalGroovyClassDoc(Throwable.class));
+            assertTrue("Expected reflective methods for java.lang.Throwable", 
docs.length > 0);
+
+            boolean foundGetMessage = false;
+            boolean foundSetStackTrace = false;
+            for (GroovyMethodDoc doc : docs) {
+                String raw = ((SimpleGroovyMethodDoc) doc).getRawCommentText();
+                if (raw == null || raw.isEmpty()) continue;
+                if ("getMessage".equals(doc.name()) && doc.parameters().length 
== 0) {
+                    foundGetMessage = true;
+                } else if ("setStackTrace".equals(doc.name()) && 
doc.parameters().length == 1) {
+                    foundSetStackTrace = true;
+                }
+            }
+            assertTrue("Expected resolved Javadoc for Throwable#getMessage()", 
foundGetMessage);
+            assertTrue("Expected resolved Javadoc for 
Throwable#setStackTrace(StackTraceElement[])"
+                    + " (bare same-package array parameter)", 
foundSetStackTrace);
+        } catch (Exception e) {
+            fail("session close threw: " + e);
+        }
+    }
 }

Reply via email to