You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/06/26 19:58:08 UTC

(groovy) 01/01: GROOVY-12096: Replace qdox with javaparser

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12096
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 6ffa1a16efc1c8f3fc9530073ce787b475351295
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Jun 27 04:57:30 2026 +0900

    GROOVY-12096: Replace qdox with javaparser
---
 build.gradle                                       |   4 +-
 gradle/verification-metadata.xml                   |   7 -
 .../tools/stubgenerator/JavaParserCategory.groovy  |  53 ++
 .../tools/stubgenerator/JavaParserSourceSet.groovy | 691 +++++++++++++++++++++
 .../groovy/tools/stubgenerator/QDoxCategory.groovy | 145 -----
 .../groovy/tools/stubgenerator/StubTestCase.groovy |  27 +-
 subprojects/groovy-docgenerator/build.gradle       |   2 +-
 .../docgenerator/JavaExtensionSourceSet.groovy     | 453 ++++++++++++++
 .../groovy/docgenerator/MockSourceGenerator.groovy | 102 ++-
 .../docgenerator/MockSourceGeneratorTest.groovy    | 116 ++++
 versions.properties                                |   1 -
 11 files changed, 1372 insertions(+), 229 deletions(-)

diff --git a/build.gradle b/build.gradle
index 6b2ae87426..87b5c3a3ad 100644
--- a/build.gradle
+++ b/build.gradle
@@ -127,7 +127,7 @@ dependencies {
     testImplementation projects.groovyDateutil
     testImplementation projects.groovyTestJunit6 // for groovy.junit6.plugin.ForkedJvm
     testImplementation "net.jcip:jcip-annotations:${versions.jcipAnnotations}"
-    testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
+    testImplementation "com.github.javaparser:javaparser-core:${versions.javaParser}"
     testImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
     testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}"
 
@@ -147,8 +147,6 @@ dependencies {
     tools "org.codehaus.plexus:plexus-utils:4.0.3"
     // end
     tools "org.ow2.asm:asm:${versions.asm}"
-    tools "com.thoughtworks.qdox:qdox:${versions.qdox}"
-
     spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0'
 
     testRuntimeOnly(project(':')) {
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index f50996fb25..1ce132ad91 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -174,7 +174,6 @@
          <trusted-key id="74CEFDCEADFB95278EDEDF71E5B3BB879D1EC979" group="me.sunlan"/>
          <trusted-key id="76E94E8FF0AB5AF3B6F8366972FEFD1572EB75E1" group="org.spockframework"/>
          <trusted-key id="7B79ADD11F8A779FE90FD3D0893A028475557671" group="com.gradle"/>
-         <trusted-key id="7E6557B314B189FD4EA5B680FE66BCA17E67AE6F" group="com.thoughtworks.qdox" name="qdox" version="2.2.0"/>
          <trusted-key id="82F94BBDF95C247BBD21396B9A0B94DEC0FFA7EE" group="org.webjars" name="jquery" version="3.7.1"/>
          <trusted-key id="8446B9E902A3F3DDEE711FDB8E26FF248BC7AEAD" group="com.github.jnr"/>
          <trusted-key id="84789D24DF77A32433CE1F079EB80E92EB2135B1" group="^org[.]apache($|([.].*))" regex="true"/>
@@ -840,12 +839,6 @@
             <sha512 value="cd57377d61d66ac2ab6ab90483252385fe2bbb9e13dee07913362a64ffe97b16082df981dbe469df3f67ad946827be6f4355d63003a393bda62a0fc8b54a590b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
          </artifact>
       </component>
-      <component group="com.thoughtworks.qdox" name="qdox" version="2.2.0">
-         <artifact name="qdox-2.2.0.jar">
-            <pgp value="7E6557B314B189FD4EA5B680FE66BCA17E67AE6F"/>
-            <sha512 value="3fec9532bd0fcbe695ac6c4c3ae54bdd83b35ec031ea81ac853a49d548d6c14bfab92e626545b90564f8424cc22d9c2c682615cf0a461273b60f127184095758" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
-         </artifact>
-      </component>
       <component group="com.thoughtworks.xstream" name="xstream" version="1.4.18">
          <artifact name="xstream-1.4.18.jar">
             <sha512 value="141c0aa3bd2e0d82cd37e6c2403f09f447aa51344fdf76c2f64b0952b1297ac99156b672628f310b4208d2387f047e62afefead519ba9eee37a7495850814b19" origin="Generated by Gradle"/>
diff --git a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserCategory.groovy b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserCategory.groovy
new file mode 100644
index 0000000000..f8c4759af2
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserCategory.groovy
@@ -0,0 +1,53 @@
+/*
+ *  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.stubgenerator
+
+import groovy.transform.CompileStatic
+
+/**
+ * Category methods that keep stub-generator assertions concise while using JavaParser-backed views.
+ */
+class JavaParserCategory {
+
+    /**
+     * Access a parsed class by fully qualified name.
+     */
+    static JavaSourceClass getAt(JavaSourceClass[] self, String className) {
+        def clazz = self.find { JavaSourceClass jc -> jc.fullyQualifiedName == className }
+        assert clazz, "No stub class found for name $className, among ${self.collect { it.fullyQualifiedName }}"
+        clazz
+    }
+
+    /**
+     * Access one or several parsed methods by name.
+     */
+    static getAt(JavaSourceMethod[] self, String methodName) {
+        getAt(self.toList(), methodName)
+    }
+
+    static getAt(List<JavaSourceMethod> self, String methodName) {
+        def methods = self.findAll { JavaSourceMethod method -> method.name == methodName }
+        methods.size() == 1 ? methods[0] : methods
+    }
+
+    @CompileStatic
+    static List<JavaSourceMethod> getMethods(JavaSourceClass self) {
+        self.methods
+    }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserSourceSet.groovy b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserSourceSet.groovy
new file mode 100644
index 0000000000..79ae962562
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/JavaParserSourceSet.groovy
@@ -0,0 +1,691 @@
+/*
+ *  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.stubgenerator
+
+import com.github.javaparser.JavaParser
+import com.github.javaparser.ParseProblemException
+import com.github.javaparser.ParseResult
+import com.github.javaparser.ParserConfiguration
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.ImportDeclaration
+import com.github.javaparser.ast.body.BodyDeclaration
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
+import com.github.javaparser.ast.body.ConstructorDeclaration
+import com.github.javaparser.ast.body.EnumDeclaration
+import com.github.javaparser.ast.body.FieldDeclaration
+import com.github.javaparser.ast.body.MethodDeclaration
+import com.github.javaparser.ast.body.RecordDeclaration
+import com.github.javaparser.ast.body.TypeDeclaration
+import com.github.javaparser.ast.expr.AnnotationExpr
+import com.github.javaparser.ast.expr.ArrayInitializerExpr
+import com.github.javaparser.ast.expr.BinaryExpr
+import com.github.javaparser.ast.expr.BooleanLiteralExpr
+import com.github.javaparser.ast.expr.CastExpr
+import com.github.javaparser.ast.expr.CharLiteralExpr
+import com.github.javaparser.ast.expr.ClassExpr
+import com.github.javaparser.ast.expr.DoubleLiteralExpr
+import com.github.javaparser.ast.expr.EnclosedExpr
+import com.github.javaparser.ast.expr.Expression
+import com.github.javaparser.ast.expr.FieldAccessExpr
+import com.github.javaparser.ast.expr.IntegerLiteralExpr
+import com.github.javaparser.ast.expr.LongLiteralExpr
+import com.github.javaparser.ast.expr.MarkerAnnotationExpr
+import com.github.javaparser.ast.expr.MethodCallExpr
+import com.github.javaparser.ast.expr.NameExpr
+import com.github.javaparser.ast.expr.NormalAnnotationExpr
+import com.github.javaparser.ast.expr.NullLiteralExpr
+import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr
+import com.github.javaparser.ast.expr.StringLiteralExpr
+import com.github.javaparser.ast.expr.TextBlockLiteralExpr
+import com.github.javaparser.ast.expr.UnaryExpr
+import com.github.javaparser.ast.type.ArrayType
+import com.github.javaparser.ast.type.ClassOrInterfaceType
+import com.github.javaparser.ast.type.IntersectionType
+import com.github.javaparser.ast.type.Type
+import com.github.javaparser.ast.type.TypeParameter
+import com.github.javaparser.ast.type.UnionType
+import com.github.javaparser.ast.type.VoidType
+import com.github.javaparser.ast.type.WildcardType
+import org.codehaus.groovy.control.ResolveVisitor
+
+import static groovy.io.FileType.FILES
+
+/**
+ * JavaParser-backed source set used by stub-generator tests to inspect generated Java stubs.
+ */
+final class JavaParserSourceSet {
+    private final JavaParser parser = new JavaParser(
+            new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)
+    )
+    private final List<ParsedUnit> units = []
+    private boolean dirty = true
+    private List<JavaSourceClass> cachedClasses = []
+
+    void addSourceTree(File root) {
+        if (!root?.exists()) return
+
+        root.eachFileRecurse(FILES) { File file ->
+            if (!file.name.endsWith('.java')) return
+
+            ParseResult<CompilationUnit> result
+            try {
+                result = parser.parse(file)
+            } catch (ParseProblemException e) {
+                throw new IllegalStateException("Unable to parse ${file}: ${e.message}", e)
+            }
+
+            if (!result.result.present) {
+                def details = result.problems.collect { it.toString() }.join(System.lineSeparator())
+                throw new IllegalStateException("Unable to parse ${file}:${System.lineSeparator()}${details}")
+            }
+
+            units << new ParsedUnit(
+                    file: file,
+                    text: file.getText('UTF-8'),
+                    compilationUnit: result.result.get()
+            )
+            dirty = true
+        }
+    }
+
+    JavaSourceClass[] getClasses() {
+        rebuildIfNeeded()
+        cachedClasses as JavaSourceClass[]
+    }
+
+    private void rebuildIfNeeded() {
+        if (!dirty) return
+
+        Map<TypeDeclaration<?>, String> fqcnByDeclaration = [:]
+        List<TypeRecord> orderedTypes = []
+        units.each { ParsedUnit unit ->
+            String packageName = unit.compilationUnit.packageDeclaration.map { it.nameAsString }.orElse('')
+            unit.compilationUnit.types.each { TypeDeclaration<?> declaration ->
+                collectTypes(unit, declaration, packageName, null, fqcnByDeclaration, orderedTypes)
+            }
+        }
+
+        Map<String, Set<String>> simpleNameIndex = [:].withDefault { new LinkedHashSet<String>() }
+        Set<String> knownTypes = new LinkedHashSet<>(fqcnByDeclaration.values())
+        knownTypes.each { String fqcn ->
+            simpleNameIndex[simpleNameOf(fqcn)] << fqcn
+        }
+
+        Map<CompilationUnit, JavaSourceContext> contextsByUnit = [:]
+        units.each { ParsedUnit unit ->
+            Set<String> unitTypes = fqcnByDeclaration.findAll { TypeDeclaration<?> declaration, String ignored ->
+                declaration.findCompilationUnit().orElse(null) == unit.compilationUnit
+            }.values() as Set<String>
+            contextsByUnit[unit.compilationUnit] = new JavaSourceContext(unit, fqcnByDeclaration, simpleNameIndex, knownTypes, unitTypes)
+        }
+
+        cachedClasses = orderedTypes.collect { TypeRecord record ->
+            new JavaSourceClass(record.fqcn, record.declaration, contextsByUnit[record.unit.compilationUnit])
+        }
+        dirty = false
+    }
+
+    private static void collectTypes(
+            ParsedUnit unit,
+            TypeDeclaration<?> declaration,
+            String packageName,
+            String ownerFqcn,
+            Map<TypeDeclaration<?>, String> fqcnByDeclaration,
+            List<TypeRecord> orderedTypes
+    ) {
+        String fqcn = ownerFqcn ? ownerFqcn + '.' + declaration.nameAsString
+                : (packageName ? packageName + '.' + declaration.nameAsString : declaration.nameAsString)
+        fqcnByDeclaration[declaration] = fqcn
+        orderedTypes << new TypeRecord(unit: unit, declaration: declaration, fqcn: fqcn)
+
+        declaration.members.findAll { BodyDeclaration member -> member instanceof TypeDeclaration }
+                .each { TypeDeclaration nested ->
+                    collectTypes(unit, nested, packageName, fqcn, fqcnByDeclaration, orderedTypes)
+                }
+    }
+
+    private static String simpleNameOf(String fqcn) {
+        int dot = fqcn.lastIndexOf('.')
+        dot < 0 ? fqcn : fqcn.substring(dot + 1)
+    }
+}
+
+final class JavaSourceClass {
+    final String fullyQualifiedName
+    final JavaSourceFile source
+    final List<JavaSourceMethod> methods
+    final List<JavaSourceConstructor> constructors
+    final List<JavaSourceAnnotation> annotations
+
+    private final TypeDeclaration<?> declaration
+    private final JavaSourceContext context
+    private final Map<String, JavaSourceField> fieldsByName
+
+    JavaSourceClass(String fullyQualifiedName, TypeDeclaration<?> declaration, JavaSourceContext context) {
+        this.fullyQualifiedName = fullyQualifiedName
+        this.declaration = declaration
+        this.context = context
+        this.source = context.source
+        this.methods = declaration.members.findAll { it instanceof MethodDeclaration }
+                .collect { new JavaSourceMethod((MethodDeclaration) it, this, context) }
+        this.constructors = declaration.members.findAll { it instanceof ConstructorDeclaration }
+                .collect { new JavaSourceConstructor((ConstructorDeclaration) it, this, context) }
+        this.annotations = declaration.annotations.collect { new JavaSourceAnnotation(it, context) }
+        this.fieldsByName = [:]
+        declaration.members.findAll { it instanceof FieldDeclaration }.each { FieldDeclaration field ->
+            field.variables.each { variable ->
+                fieldsByName[variable.nameAsString] = new JavaSourceField(variable.nameAsString, variable.initializer.map {
+                    context.renderExpression(it)
+                }.orElse(null))
+            }
+        }
+    }
+
+    JavaSourceField getFieldByName(String fieldName) {
+        fieldsByName[fieldName]
+    }
+
+    List<String> getImports() {
+        source.imports
+    }
+
+    List<String> getInterfaces() {
+        if (declaration instanceof ClassOrInterfaceDeclaration) {
+            def classOrInterface = (ClassOrInterfaceDeclaration) declaration
+            def types = classOrInterface.isInterface() ? classOrInterface.extendedTypes : classOrInterface.implementedTypes
+            return types.collect { context.renderType(it, typeParameterNames()) }
+        }
+        if (declaration instanceof EnumDeclaration) {
+            return ((EnumDeclaration) declaration).implementedTypes.collect { context.renderType(it, typeParameterNames()) }
+        }
+        if (declaration instanceof RecordDeclaration) {
+            return ((RecordDeclaration) declaration).implementedTypes.collect { context.renderType(it, typeParameterNames()) }
+        }
+        []
+    }
+
+    String getBaseClass() {
+        if (declaration instanceof ClassOrInterfaceDeclaration) {
+            def classOrInterface = (ClassOrInterfaceDeclaration) declaration
+            if (classOrInterface.isInterface()) return null
+            if (!classOrInterface.extendedTypes.empty) {
+                return context.renderType(classOrInterface.extendedTypes[0], typeParameterNames())
+            }
+            return 'java.lang.Object'
+        }
+        if (declaration instanceof EnumDeclaration) {
+            return 'java.lang.Enum'
+        }
+        if (declaration instanceof RecordDeclaration) {
+            return 'java.lang.Record'
+        }
+        null
+    }
+
+    Set<String> typeParameterNames() {
+        declaration.respondsTo('getTypeParameters')
+                ? declaration.typeParameters*.nameAsString as Set<String>
+                : Collections.emptySet()
+    }
+}
+
+final class JavaSourceMethod {
+    final String name
+    final List<JavaSourceAnnotation> annotations
+
+    private final MethodDeclaration declaration
+    private final JavaSourceClass owner
+    private final JavaSourceContext context
+
+    JavaSourceMethod(MethodDeclaration declaration, JavaSourceClass owner, JavaSourceContext context) {
+        this.declaration = declaration
+        this.owner = owner
+        this.context = context
+        this.name = declaration.nameAsString
+        this.annotations = declaration.annotations.collect { new JavaSourceAnnotation(it, context) }
+    }
+
+    String getSignature() {
+        def scopeTypeParameters = new LinkedHashSet<String>(owner.typeParameterNames())
+        scopeTypeParameters.addAll(declaration.typeParameters*.nameAsString)
+
+        def parts = []
+        if (!declaration.modifiers.empty) {
+            parts << declaration.modifiers.collect { it.keyword.asString() }.join(' ')
+        }
+        if (!declaration.typeParameters.empty) {
+            parts << '<' + declaration.typeParameters.collect {
+                context.renderTypeParameter(it, scopeTypeParameters)
+            }.join(', ') + '>'
+        }
+        parts << context.renderType(declaration.type, scopeTypeParameters)
+        parts << "${declaration.nameAsString}(" + declaration.parameters.collect {
+            context.renderParameter(it, scopeTypeParameters)
+        }.join(', ') + ')'
+
+        String signature = parts.findAll { it }.join(' ')
+        if (!declaration.thrownExceptions.empty) {
+            signature += ' throws ' + declaration.thrownExceptions.collect {
+                context.renderType(it, scopeTypeParameters)
+            }.join(', ')
+        }
+        signature
+    }
+}
+
+final class JavaSourceConstructor {
+    private final ConstructorDeclaration declaration
+    private final JavaSourceClass owner
+    private final JavaSourceContext context
+
+    JavaSourceConstructor(ConstructorDeclaration declaration, JavaSourceClass owner, JavaSourceContext context) {
+        this.declaration = declaration
+        this.owner = owner
+        this.context = context
+    }
+
+    @Override
+    String toString() {
+        def scopeTypeParameters = new LinkedHashSet<String>(owner.typeParameterNames())
+        scopeTypeParameters.addAll(declaration.typeParameters*.nameAsString)
+
+        def parts = []
+        if (!declaration.modifiers.empty) {
+            parts << declaration.modifiers.collect { it.keyword.asString() }.join(' ')
+        }
+        parts << owner.fullyQualifiedName + '(' + declaration.parameters.collect {
+            context.renderType(it.type, scopeTypeParameters, it.varArgs)
+        }.join(',') + ')'
+
+        String signature = parts.findAll { it }.join(' ')
+        if (!declaration.thrownExceptions.empty) {
+            signature += ' throws ' + declaration.thrownExceptions.collect {
+                context.renderType(it, scopeTypeParameters)
+            }.join(', ')
+        }
+        signature
+    }
+}
+
+final class JavaSourceAnnotation {
+    private final AnnotationExpr expression
+    private final JavaSourceContext context
+
+    JavaSourceAnnotation(AnnotationExpr expression, JavaSourceContext context) {
+        this.expression = expression
+        this.context = context
+    }
+
+    String getType() {
+        context.resolveTypeName(expression.nameAsString)
+    }
+
+    Object getProperty(String name) {
+        if (name == 'type') return getType()
+        if (name == 'class') return getClass()
+        if (name == 'metaClass') return GroovySystem.metaClassRegistry.getMetaClass(getClass())
+
+        annotationProperty(name)
+    }
+
+    private JavaSourceAnnotationValue annotationProperty(String name) {
+        Expression value = null
+        if (expression instanceof NormalAnnotationExpr) {
+            value = ((NormalAnnotationExpr) expression).pairs.find { it.nameAsString == name }?.value
+        } else if (expression instanceof SingleMemberAnnotationExpr && name == 'value') {
+            value = ((SingleMemberAnnotationExpr) expression).memberValue
+        }
+        value != null ? new JavaSourceAnnotationValue(value, context) : null
+    }
+
+    String getNamedParameter(String name) {
+        annotationProperty(name)?.toString()
+    }
+
+    @Override
+    String toString() {
+        context.renderAnnotation(expression)
+    }
+}
+
+final class JavaSourceAnnotationValue {
+    private final Expression expression
+    private final JavaSourceContext context
+
+    JavaSourceAnnotationValue(Expression expression, JavaSourceContext context) {
+        this.expression = expression
+        this.context = context
+    }
+
+    JavaSourceAnnotationValue getParameterValue() {
+        this
+    }
+
+    Object getProperty(String name) {
+        if (name == 'parameterValue') return getParameterValue()
+        if (name == 'value') return getValue()
+        if (name == 'class') return getClass()
+        if (name == 'metaClass') return GroovySystem.metaClassRegistry.getMetaClass(getClass())
+
+        expression instanceof AnnotationExpr
+                ? new JavaSourceAnnotation((AnnotationExpr) expression, context).getProperty(name)
+                : null
+    }
+
+    String getValue() {
+        if (expression instanceof StringLiteralExpr) {
+            return ((StringLiteralExpr) expression).asString()
+        }
+        if (expression instanceof TextBlockLiteralExpr) {
+            return ((TextBlockLiteralExpr) expression).asString()
+        }
+        context.renderExpression(expression)
+    }
+
+    @Override
+    String toString() {
+        context.renderExpression(expression)
+    }
+}
+
+final class JavaSourceField {
+    final String name
+    final String initializationExpression
+
+    JavaSourceField(String name, String initializationExpression) {
+        this.name = name
+        this.initializationExpression = initializationExpression
+    }
+}
+
+final class JavaSourceFile {
+    final String text
+    final List<String> imports
+
+    JavaSourceFile(String text, List<String> imports) {
+        this.text = text
+        this.imports = imports
+    }
+
+    @Override
+    String toString() {
+        text
+    }
+}
+
+final class JavaSourceContext {
+    private static final Set<String> PRIMITIVES = [
+            'boolean', 'byte', 'char', 'double', 'float', 'int', 'long', 'short'
+    ] as Set<String>
+
+    final JavaSourceFile source
+    final String packageName
+
+    private final Map<String, String> explicitImports = [:]
+    private final List<String> wildcardImports = []
+    private final Map<TypeDeclaration<?>, String> fqcnByDeclaration
+    private final Map<String, Set<String>> simpleNameIndex
+    private final Set<String> knownTypes
+    private final Set<String> unitTypes
+
+    JavaSourceContext(
+            ParsedUnit unit,
+            Map<TypeDeclaration<?>, String> fqcnByDeclaration,
+            Map<String, Set<String>> simpleNameIndex,
+            Set<String> knownTypes,
+            Set<String> unitTypes
+    ) {
+        this.packageName = unit.compilationUnit.packageDeclaration.map { it.nameAsString }.orElse('')
+        this.source = new JavaSourceFile(unit.text, collectImports(unit.compilationUnit))
+        this.fqcnByDeclaration = fqcnByDeclaration
+        this.simpleNameIndex = simpleNameIndex
+        this.knownTypes = knownTypes
+        this.unitTypes = unitTypes
+
+        unit.compilationUnit.imports.each { ImportDeclaration importDeclaration ->
+            if (importDeclaration.isStatic()) return
+            if (importDeclaration.asterisk) {
+                wildcardImports << importDeclaration.nameAsString
+            } else {
+                explicitImports[importDeclaration.name.identifier] = importDeclaration.nameAsString
+            }
+        }
+    }
+
+    String renderParameter(parameter, Set<String> typeParameterNames) {
+        "${renderType(parameter.type, typeParameterNames, parameter.varArgs)} ${parameter.nameAsString}"
+    }
+
+    String renderType(Type type, Set<String> typeParameterNames = Collections.emptySet(), boolean varArgs = false) {
+        String rendered
+        if (type instanceof VoidType) {
+            rendered = 'void'
+        } else if (type instanceof ArrayType) {
+            rendered = renderType(type.componentType, typeParameterNames) + '[]'
+        } else if (type instanceof ClassOrInterfaceType) {
+            rendered = renderClassOrInterfaceType((ClassOrInterfaceType) type, typeParameterNames)
+        } else if (type instanceof WildcardType) {
+            def wildcard = (WildcardType) type
+            if (wildcard.extendedType.present) {
+                rendered = '? extends ' + renderType(wildcard.extendedType.get(), typeParameterNames)
+            } else if (wildcard.superType.present) {
+                rendered = '? super ' + renderType(wildcard.superType.get(), typeParameterNames)
+            } else {
+                rendered = '?'
+            }
+        } else if (type instanceof IntersectionType) {
+            rendered = ((IntersectionType) type).elements.collect {
+                renderType(it, typeParameterNames)
+            }.join(' & ')
+        } else if (type instanceof UnionType) {
+            rendered = ((UnionType) type).elements.collect {
+                renderType(it, typeParameterNames)
+            }.join(' | ')
+        } else {
+            rendered = type.toString()
+        }
+
+        if (varArgs && rendered.endsWith('[]')) {
+            rendered = rendered[0..-3] + '...'
+        }
+        rendered
+    }
+
+    String renderTypeParameter(TypeParameter typeParameter, Set<String> inheritedTypeParameters) {
+        def scopeTypeParameters = new LinkedHashSet<String>(inheritedTypeParameters)
+        scopeTypeParameters << typeParameter.nameAsString
+
+        def rendered = new StringBuilder(typeParameter.nameAsString)
+        if (!typeParameter.typeBound.empty) {
+            rendered << ' extends ' << typeParameter.typeBound.collect {
+                renderType(it, scopeTypeParameters)
+            }.join(' & ')
+        }
+        rendered.toString()
+    }
+
+    String renderExpression(Expression expression) {
+        if (expression instanceof StringLiteralExpr ||
+                expression instanceof TextBlockLiteralExpr ||
+                expression instanceof IntegerLiteralExpr ||
+                expression instanceof LongLiteralExpr ||
+                expression instanceof DoubleLiteralExpr ||
+                expression instanceof BooleanLiteralExpr ||
+                expression instanceof CharLiteralExpr ||
+                expression instanceof NullLiteralExpr) {
+            return expression.toString()
+        }
+        if (expression instanceof NameExpr) {
+            return resolveTypeName(((NameExpr) expression).nameAsString)
+        }
+        if (expression instanceof FieldAccessExpr) {
+            def fieldAccess = (FieldAccessExpr) expression
+            return renderExpression(fieldAccess.scope) + '.' + fieldAccess.nameAsString
+        }
+        if (expression instanceof ClassExpr) {
+            return renderType(((ClassExpr) expression).type) + '.class'
+        }
+        if (expression instanceof AnnotationExpr) {
+            return renderAnnotation((AnnotationExpr) expression)
+        }
+        if (expression instanceof ArrayInitializerExpr) {
+            return '{' + ((ArrayInitializerExpr) expression).values.collect {
+                renderExpression(it)
+            }.join(', ') + '}'
+        }
+        if (expression instanceof EnclosedExpr) {
+            return '(' + renderExpression(((EnclosedExpr) expression).inner) + ')'
+        }
+        if (expression instanceof CastExpr) {
+            def cast = (CastExpr) expression
+            return '(' + renderType(cast.type) + ') ' + renderExpression(cast.expression)
+        }
+        if (expression instanceof UnaryExpr) {
+            def unary = (UnaryExpr) expression
+            return unary.operator.asString() + renderExpression(unary.expression)
+        }
+        if (expression instanceof BinaryExpr) {
+            def binary = (BinaryExpr) expression
+            return renderExpression(binary.left) + ' ' + binary.operator.asString() + ' ' + renderExpression(binary.right)
+        }
+        if (expression instanceof MethodCallExpr) {
+            def call = (MethodCallExpr) expression
+            def prefix = call.scope.map { renderExpression(it) + '.' }.orElse('')
+            return prefix + call.nameAsString + '(' + call.arguments.collect { renderExpression(it) }.join(', ') + ')'
+        }
+        expression.toString()
+    }
+
+    String renderAnnotation(AnnotationExpr annotation) {
+        String typeName = resolveTypeName(annotation.nameAsString)
+        if (annotation instanceof MarkerAnnotationExpr) {
+            return "@${typeName}"
+        }
+        if (annotation instanceof SingleMemberAnnotationExpr) {
+            return "@${typeName}(" + renderExpression(((SingleMemberAnnotationExpr) annotation).memberValue) + ')'
+        }
+        def normal = (NormalAnnotationExpr) annotation
+        "@${typeName}(" + normal.pairs.collect {
+            "${it.nameAsString} = ${renderExpression(it.value)}"
+        }.join(', ') + ')'
+    }
+
+    String resolveTypeName(String name, Set<String> typeParameterNames = Collections.emptySet()) {
+        if (!name) return name
+        if (PRIMITIVES.contains(name) || name == 'void' || typeParameterNames.contains(name)) return name
+
+        int dot = name.indexOf('.')
+        if (dot < 0) return resolveSimpleName(name, typeParameterNames)
+
+        String first = name.substring(0, dot)
+        if (!Character.isUpperCase(first.charAt(0)) && !explicitImports.containsKey(first)) {
+            return name
+        }
+        String resolvedFirst = resolveSimpleName(first, typeParameterNames)
+        resolvedFirst == first ? name : resolvedFirst + name.substring(dot)
+    }
+
+    private String renderClassOrInterfaceType(ClassOrInterfaceType type, Set<String> typeParameterNames) {
+        String name
+        if (type.scope.present) {
+            name = rawScopedName(type.scope.get(), typeParameterNames) + '.' + type.nameAsString
+        } else {
+            name = resolveSimpleName(type.nameAsString, typeParameterNames)
+        }
+        if (!type.typeArguments.present || type.typeArguments.get().empty) {
+            return name
+        }
+        name + '<' + type.typeArguments.get().collect { renderType(it, typeParameterNames) }.join(', ') + '>'
+    }
+
+    private String rawScopedName(ClassOrInterfaceType type, Set<String> typeParameterNames) {
+        if (type.scope.present) {
+            return rawScopedName(type.scope.get(), typeParameterNames) + '.' + type.nameAsString
+        }
+        resolveSimpleName(type.nameAsString, typeParameterNames)
+    }
+
+    private String resolveSimpleName(String name, Set<String> typeParameterNames) {
+        if (PRIMITIVES.contains(name) || name == 'void' || typeParameterNames.contains(name)) return name
+        if (explicitImports.containsKey(name)) return explicitImports[name]
+
+        if (packageName) {
+            String candidate = packageName + '.' + name
+            if (knownType(candidate)) return candidate
+        }
+
+        Set<String> localMatches = unitTypes.findAll { String candidate -> candidate.endsWith('.' + name) || candidate == name } as Set<String>
+        if (localMatches.size() == 1) return localMatches.first()
+
+        for (String importPrefix : wildcardImports) {
+            String candidate = importPrefix + '.' + name
+            if (knownType(candidate)) return candidate
+        }
+
+        for (String defaultImport : ResolveVisitor.DEFAULT_IMPORTS) {
+            String candidate = defaultImport + name
+            if (knownType(candidate)) return candidate
+        }
+
+        Set<String> matches = simpleNameIndex[name]
+        if (matches?.size() == 1) return matches.first()
+
+        name
+    }
+
+    private boolean knownType(String candidate) {
+        knownTypes.contains(candidate) || tryLoad(candidate) != null
+    }
+
+    private static Class<?> tryLoad(String candidate) {
+        if (!candidate) return null
+        try {
+            return Class.forName(candidate)
+        } catch (Throwable ignored) {
+            int lastDot = candidate.lastIndexOf('.')
+            while (lastDot > 0) {
+                candidate = candidate.substring(0, lastDot) + '$' + candidate.substring(lastDot + 1)
+                try {
+                    return Class.forName(candidate)
+                } catch (Throwable ignoredAgain) {
+                    lastDot = candidate.lastIndexOf('.')
+                }
+            }
+            return null
+        }
+    }
+
+    private static List<String> collectImports(CompilationUnit compilationUnit) {
+        compilationUnit.imports.findAll { !it.isStatic() }.collect { ImportDeclaration importDeclaration ->
+            importDeclaration.asterisk ? importDeclaration.nameAsString + '.*' : importDeclaration.nameAsString
+        }
+    }
+}
+
+final class ParsedUnit {
+    File file
+    String text
+    CompilationUnit compilationUnit
+}
+
+final class TypeRecord {
+    ParsedUnit unit
+    TypeDeclaration<?> declaration
+    String fqcn
+}
diff --git a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/QDoxCategory.groovy b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/QDoxCategory.groovy
deleted file mode 100644
index 99265ecc1a..0000000000
--- a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/QDoxCategory.groovy
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- *  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.stubgenerator
-
-import com.thoughtworks.qdox.model.JavaClass
-import com.thoughtworks.qdox.model.JavaMethod
-import groovy.transform.CompileStatic
-
-/**
- * Category methods for the QDox library to simplify the writing assertions to check the generated stubs.
- */
-class QDoxCategory {
-
-    /**
-     * Given a <code>JavaClass[]</code> array,
-     * like provided by the <code>getClasses()</code> method (or <code>classes</code> property) in <code>StubTestCase</code>,
-     * you can access a specific class by its fully qualified name, using the subscript notation:
-     *
-     * <pre><code>
-     * classes['com.bar.Rectangle']
-     * </code></pre>
-     *
-     * @param self an array of <code>JavaClass[]</code>
-     * @param className the name of the class to find
-     * @return a <code>JavaClass</code>
-     */
-    static JavaClass getAt(JavaClass[] self, String className) {
-        def clazz = self.find { JavaClass jc -> jc.fullyQualifiedName == className }
-        assert clazz, "No stub class found for name $className, among ${self.collect { it.fullyQualifiedName }}"
-        return clazz
-    }
-
-    /**
-     * Find one or several methods from a <code>JavaMethod[]</code> array.
-     *
-     * Usage:
-     *
-     * <pre><code>
-     * classes['com.bar.Rectangle'].methods['area']
-     * </code></pre>
-     *
-     * @param self an array of <code>JavaMethod</code>
-     * @param methodName the name of the method(s) to find
-     * @return a single <code>JavaMethod</code> if only one of that name exists, or a <code>JavaMethod[]</code> array
-     */
-    static getAt(JavaMethod[] self, String methodName) {
-        def methods = self.findAll { JavaMethod jc -> jc.name == methodName }
-        if (methods.size() == 1) {
-            return methods[0]
-        } else {
-            return methods
-        }
-    }
-
-    @CompileStatic
-    static JavaMethod[] getMethods(JavaClass self) {
-        self.methods as JavaMethod[]
-    }
-
-    /**
-     * Get a normalized string of the signature of a method, including its modifiers.
-     * <br><br>
-     * Usage:
-     *
-     * <pre><code>
-     * // an area() method
-     * assert classes['Rectangle'].methods['area'].signature == "public double area()"
-     * // a constructor
-     * assert classes['Rectangle'].methods['Rectangle'].signature == "public Rectangle(double x, double y)"
-     * </code></pre>
-     *
-     * @param self a method
-     * @return a normalized strings representing the signature
-     */
-    static String getSignature(JavaMethod self) {
-        self.getDeclarationSignature(true)
-    }
-
-    /**
-     * Lists the interfaces implemented by a class.
-     * <br><br>
-     * Usage:
-     *
-     * <pre><code>
-     *  assert classes['Rectangle'].interfaces == ['groovy.lang.GroovyObject', 'Shape']
-     * </code></pre>
-     *
-     * @param self a <code>JavaClass</code>
-     * @return a list of fully qualified interface names
-     */
-    @CompileStatic
-    static List<String> getInterfaces(JavaClass self) {
-        self.interfaces*.fullyQualifiedName
-    }
-
-    /**
-     * Get the name of the base class of that class.
-     * <br><br>
-     * Usage:
-     *
-     * <pre><code>
-     *  assert classes['Rectangle'].baseClass == 'java.lang.Object'
-     * </code></pre>
-     *
-     * @param self the class for which we want to know the base class
-     * @return the fully qualified name of the parent class
-     */
-    static String getBaseClass(JavaClass self) {
-        self.superJavaClass.fullyQualifiedName
-    }
-
-    /**
-     * Shortcut to get the imports from the source associated with the class.
-     * <br><br>
-     * Usage:
-     *
-     * <pre><code>
-     *  assert classes['Rectangle'].imports == ['java.lang.*', 'java.io.*',
-     *      'java.net.*', 'java.util.*',
-     *      'groovy.lang.*', 'groovy.util.*']
-     * </code></pre>
-     *
-     * @param self the class
-     * @return the list of fully qualified imports
-     */
-    static List<String> getImports(JavaClass self) {
-        self.source.imports
-    }
-}
diff --git a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/StubTestCase.groovy b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/StubTestCase.groovy
index 6c1b1b8e30..8454388b87 100644
--- a/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/StubTestCase.groovy
+++ b/src/test/groovy/org/codehaus/groovy/tools/stubgenerator/StubTestCase.groovy
@@ -18,8 +18,6 @@
  */
 package org.codehaus.groovy.tools.stubgenerator
 
-import com.thoughtworks.qdox.JavaProjectBuilder
-import com.thoughtworks.qdox.model.JavaClass
 import groovy.test.GroovyTestCase
 import junit.framework.TestCase
 import org.codehaus.groovy.control.CompilationFailedException
@@ -50,9 +48,9 @@ import static org.junit.jupiter.api.Assertions.fail
  * existing tests which use this approach, e.g. <code>DuplicateMethodAdditionInStubsTest</code>.</li>
  * </ul>
  * From within the <code>verifyStubs()</code> method, you can make various assertions on the stubs.
- * QDox is used for parsing the Java sources (both the generated stub Java sources, as well as the original Java source,
+ * JavaParser is used for parsing the Java sources (both the generated stub Java sources, as well as the original Java source,
  * but not the Groovy sources).
- * The execution of the <code>verifyStubs()</code> method is done with the <code>QDoxCategory</code> applied,
+ * The execution of the <code>verifyStubs()</code> method is done with the <code>JavaParserCategory</code> applied,
  * which provides various useful shortcuts for quickly checking the structure of your stubs.
  * <p>
  * Please have a look at the existing samples to see what kind of asserts can be done.
@@ -64,7 +62,7 @@ abstract class StubTestCase {
 
     protected File sourceRootPath
 
-    protected JavaProjectBuilder qdox = new JavaProjectBuilder()
+    protected JavaParserSourceSet parsedSources = new JavaParserSourceSet()
 
     protected GroovyClassLoader loader
     protected CompilerConfiguration config
@@ -166,9 +164,9 @@ abstract class StubTestCase {
         try {
             compile(sources)
 
-            // use QDox for parsing the Java stubs and Java sources
-            qdox.addSourceTree(sourceRootPath)
-            qdox.addSourceTree(stubDir)
+            // use JavaParser for parsing the Java stubs and Java sources
+            parsedSources.addSourceTree(sourceRootPath)
+            parsedSources.addSourceTree(stubDir)
 
             if (debug) {
                 println ">>> Stubs generated"
@@ -179,8 +177,8 @@ abstract class StubTestCase {
                     }
                 }
 
-                println ">>> QDox canonical sources"
-                qdox.classes.each { JavaClass jc ->
+                println ">>> JavaParser canonical sources"
+                parsedSources.classes.each { JavaSourceClass jc ->
                     println " -> " + jc.fullyQualifiedName
                     println jc.source
                 }
@@ -191,7 +189,7 @@ abstract class StubTestCase {
             compileError = t
         } finally {
             try {
-                use (QDoxCategory) {
+                use (JavaParserCategory) {
                     verifyStubs()
                 }
             } catch (AssertionError | RuntimeException e) {
@@ -277,10 +275,10 @@ abstract class StubTestCase {
      * Method providing a useful shortcut for the subclasses, so that you can use <code>classes</code>
      * from within the <code>verifyStubs()</code> method, to access all the stubs.
      *
-     * @return an array of QDox' <code>JavaClass</code>es.
+     * @return an array of parsed Java classes.
      */
-    protected JavaClass[] getClasses() {
-        qdox.classes as JavaClass[]
+    protected JavaSourceClass[] getClasses() {
+        parsedSources.classes as JavaSourceClass[]
     }
 
     /**
@@ -335,4 +333,3 @@ abstract class StubTestCase {
         }
     }
 }
-
diff --git a/subprojects/groovy-docgenerator/build.gradle b/subprojects/groovy-docgenerator/build.gradle
index 82cd67eb1d..4f321573a9 100644
--- a/subprojects/groovy-docgenerator/build.gradle
+++ b/subprojects/groovy-docgenerator/build.gradle
@@ -24,7 +24,7 @@ dependencies {
     implementation rootProject
     implementation projects.groovyTemplates
     implementation projects.groovyGroovydoc
-    implementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
+    implementation "com.github.javaparser:javaparser-core:${versions.javaParser}"
 
     testImplementation projects.groovyTest
 }
diff --git a/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/JavaExtensionSourceSet.groovy b/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/JavaExtensionSourceSet.groovy
new file mode 100644
index 0000000000..31f68028a2
--- /dev/null
+++ b/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/JavaExtensionSourceSet.groovy
@@ -0,0 +1,453 @@
+/*
+ *  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.apache.groovy.docgenerator
+
+import com.github.javaparser.JavaParser
+import com.github.javaparser.ParseProblemException
+import com.github.javaparser.ParseResult
+import com.github.javaparser.ParserConfiguration
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.ImportDeclaration
+import com.github.javaparser.ast.body.BodyDeclaration
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
+import com.github.javaparser.ast.body.MethodDeclaration
+import com.github.javaparser.ast.body.TypeDeclaration
+import com.github.javaparser.ast.comments.JavadocComment
+import com.github.javaparser.ast.type.ArrayType
+import com.github.javaparser.ast.type.ClassOrInterfaceType
+import com.github.javaparser.ast.type.IntersectionType
+import com.github.javaparser.ast.type.Type
+import com.github.javaparser.ast.type.TypeParameter
+import com.github.javaparser.ast.type.UnionType
+import com.github.javaparser.ast.type.VoidType
+import com.github.javaparser.ast.type.WildcardType
+import org.codehaus.groovy.control.ResolveVisitor
+
+/**
+ * JavaParser-backed view of the extension source files consumed by {@link MockSourceGenerator}.
+ */
+final class JavaExtensionSourceSet {
+    private final JavaParser parser = new JavaParser(
+            new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE)
+    )
+    private final List<ParsedUnit> units = []
+    private boolean dirty = true
+    private List<JavaExtensionMethod> cachedMethods = []
+    private Map<String, TypeDeclaration<?>> declarationsByFqcn = [:]
+    private Map<String, Set<String>> simpleNameIndex = [:].withDefault { new LinkedHashSet<String>() }
+    private Set<String> knownTypes = new LinkedHashSet<>()
+    private Map<CompilationUnit, JavaExtensionContext> contextsByUnit = [:]
+
+    void addSource(File file) {
+        if (!file?.exists()) return
+
+        ParseResult<CompilationUnit> result
+        try {
+            result = parser.parse(file)
+        } catch (ParseProblemException e) {
+            throw new IllegalStateException("Unable to parse ${file}: ${e.message}", e)
+        }
+        if (!result.result.present) {
+            def details = result.problems.collect { it.toString() }.join(System.lineSeparator())
+            throw new IllegalStateException("Unable to parse ${file}:${System.lineSeparator()}${details}")
+        }
+
+        units << new ParsedUnit(
+                file: file,
+                compilationUnit: result.result.get()
+        )
+        dirty = true
+    }
+
+    List<JavaExtensionMethod> getMethods() {
+        rebuildIfNeeded()
+        cachedMethods
+    }
+
+    ReceiverTypeInfo typeInfoForFqcn(String fqcn) {
+        rebuildIfNeeded()
+        resolveTypeInfo(fqcn)
+    }
+
+    private ReceiverTypeInfo resolveTypeInfo(String fqcn) {
+        String base = stripArraySuffix(fqcn)
+        if (!base) {
+            return new ReceiverTypeInfo(canonicalName: fqcn, primitive: false, interfaceType: false)
+        }
+
+        if (PRIMITIVES.contains(base)) {
+            return new ReceiverTypeInfo(canonicalName: fqcn, primitive: true, interfaceType: false)
+        }
+
+        TypeDeclaration<?> declaration = declarationsByFqcn[base]
+        if (declaration instanceof ClassOrInterfaceDeclaration) {
+            return new ReceiverTypeInfo(canonicalName: fqcn, primitive: false, interfaceType: declaration.isInterface())
+        }
+
+        Class<?> resolvedClass = tryLoad(base)
+        new ReceiverTypeInfo(
+                canonicalName: fqcn,
+                primitive: false,
+                interfaceType: resolvedClass?.isInterface() ?: false
+        )
+    }
+
+    private void rebuildIfNeeded() {
+        if (!dirty) return
+
+        declarationsByFqcn = [:]
+        simpleNameIndex = [:].withDefault { new LinkedHashSet<String>() }
+        knownTypes = new LinkedHashSet<>()
+        contextsByUnit = [:]
+        cachedMethods = []
+
+        units.each { ParsedUnit unit ->
+            String packageName = unit.compilationUnit.packageDeclaration.map { it.nameAsString }.orElse('')
+            unit.compilationUnit.types.each { TypeDeclaration<?> declaration ->
+                collectTypes(declaration, packageName, null)
+            }
+        }
+
+        knownTypes.addAll(declarationsByFqcn.keySet())
+        declarationsByFqcn.keySet().each { String fqcn ->
+            simpleNameIndex[simpleNameOf(fqcn)] << fqcn
+        }
+
+        units.each { ParsedUnit unit ->
+            contextsByUnit[unit.compilationUnit] = new JavaExtensionContext(unit.compilationUnit, declarationsByFqcn, simpleNameIndex, knownTypes)
+        }
+
+        declarationsByFqcn.each { String fqcn, TypeDeclaration<?> declaration ->
+            JavaExtensionContext context = contextsByUnit[declaration.findCompilationUnit().orElse(null)]
+            declaration.members.findAll { it instanceof MethodDeclaration }.each { MethodDeclaration method ->
+                cachedMethods << createMethodView(method, declaration, context)
+            }
+        }
+        dirty = false
+    }
+
+    private void collectTypes(TypeDeclaration<?> declaration, String packageName, String ownerFqcn) {
+        String fqcn = ownerFqcn ? ownerFqcn + '.' + declaration.nameAsString
+                : (packageName ? packageName + '.' + declaration.nameAsString : declaration.nameAsString)
+        declarationsByFqcn[fqcn] = declaration
+        declaration.members.findAll { BodyDeclaration member -> member instanceof TypeDeclaration }
+                .each { TypeDeclaration nested ->
+                    collectTypes(nested, packageName, fqcn)
+                }
+    }
+
+    private JavaExtensionMethod createMethodView(MethodDeclaration method, TypeDeclaration<?> declaringType, JavaExtensionContext context) {
+        def ownerTypeParameters = declaringType.respondsTo('getTypeParameters')
+                ? declaringType.typeParameters*.nameAsString
+                : []
+        def scopeTypeParameters = new LinkedHashSet<String>(ownerTypeParameters)
+        scopeTypeParameters.addAll(method.typeParameters*.nameAsString)
+
+        List<JavaExtensionParameter> parameters = method.parameters.collect { parameter ->
+            new JavaExtensionParameter(
+                    name: parameter.nameAsString,
+                    type: context.renderType(parameter.type, scopeTypeParameters, parameter.varArgs),
+                    varArgs: parameter.varArgs
+            )
+        }
+
+        String receiverTypeName = parameters
+                ? MockSourceGenerator.resolveJdkClassName(context.eraseType(method.parameters[0].type, scopeTypeParameters))
+                : null
+
+        new JavaExtensionMethod(
+                name: method.nameAsString,
+                declaringClassName: declaringType.nameAsString,
+                publicMethod: method.isPublic(),
+                staticMethod: method.isStatic(),
+                deprecated: method.annotations.any { context.resolveTypeName(it.nameAsString) == 'java.lang.Deprecated' },
+                receiverTypeName: receiverTypeName,
+                receiverTypeInfo: receiverTypeName ? resolveTypeInfo(receiverTypeName) : null,
+                returnType: context.renderType(method.type, scopeTypeParameters),
+                parameters: parameters,
+                typeParameters: method.typeParameters.collect {
+                    context.renderTypeParameter(it, scopeTypeParameters)
+                },
+                exceptions: method.thrownExceptions.collect {
+                    context.renderType(it, scopeTypeParameters)
+                },
+                javadoc: JavadocInfo.parse(method.javadocComment.orElse(null))
+        )
+    }
+
+    private static String stripArraySuffix(String name) {
+        name?.replaceAll(/(\[\])+$/, '')
+    }
+
+    private static String simpleNameOf(String fqcn) {
+        int dot = fqcn.lastIndexOf('.')
+        dot < 0 ? fqcn : fqcn.substring(dot + 1)
+    }
+
+    static Class<?> tryLoad(String candidate) {
+        if (!candidate) return null
+        try {
+            return Class.forName(candidate)
+        } catch (Throwable ignored) {
+            int lastDot = candidate.lastIndexOf('.')
+            while (lastDot > 0) {
+                candidate = candidate.substring(0, lastDot) + '$' + candidate.substring(lastDot + 1)
+                try {
+                    return Class.forName(candidate)
+                } catch (Throwable ignoredAgain) {
+                    lastDot = candidate.lastIndexOf('.')
+                }
+            }
+            return null
+        }
+    }
+
+    private static final Set<String> PRIMITIVES = [
+            'boolean', 'byte', 'char', 'double', 'float', 'int', 'long', 'short'
+    ] as Set<String>
+}
+
+final class JavaExtensionContext {
+    private static final Set<String> PRIMITIVES = [
+            'boolean', 'byte', 'char', 'double', 'float', 'int', 'long', 'short'
+    ] as Set<String>
+
+    private final Map<String, String> explicitImports = [:]
+    private final List<String> wildcardImports = []
+    private final Map<String, TypeDeclaration<?>> declarationsByFqcn
+    private final Map<String, Set<String>> simpleNameIndex
+    private final Set<String> knownTypes
+    private final String packageName
+
+    JavaExtensionContext(
+            CompilationUnit compilationUnit,
+            Map<String, TypeDeclaration<?>> declarationsByFqcn,
+            Map<String, Set<String>> simpleNameIndex,
+            Set<String> knownTypes
+    ) {
+        this.declarationsByFqcn = declarationsByFqcn
+        this.simpleNameIndex = simpleNameIndex
+        this.knownTypes = knownTypes
+        this.packageName = compilationUnit.packageDeclaration.map { it.nameAsString }.orElse('')
+
+        compilationUnit.imports.each { ImportDeclaration importDeclaration ->
+            if (importDeclaration.isStatic()) return
+            if (importDeclaration.asterisk) {
+                wildcardImports << importDeclaration.nameAsString
+            } else {
+                explicitImports[importDeclaration.name.identifier] = importDeclaration.nameAsString
+            }
+        }
+    }
+
+    String renderType(Type type, Set<String> typeParameterNames = Collections.emptySet(), boolean varArgs = false) {
+        String rendered
+        if (type instanceof VoidType) {
+            rendered = 'void'
+        } else if (type instanceof ArrayType) {
+            rendered = renderType(type.componentType, typeParameterNames) + '[]'
+        } else if (type instanceof ClassOrInterfaceType) {
+            rendered = renderClassOrInterfaceType((ClassOrInterfaceType) type, typeParameterNames)
+        } else if (type instanceof WildcardType) {
+            def wildcard = (WildcardType) type
+            if (wildcard.extendedType.present) {
+                rendered = '? extends ' + renderType(wildcard.extendedType.get(), typeParameterNames)
+            } else if (wildcard.superType.present) {
+                rendered = '? super ' + renderType(wildcard.superType.get(), typeParameterNames)
+            } else {
+                rendered = '?'
+            }
+        } else if (type instanceof IntersectionType) {
+            rendered = ((IntersectionType) type).elements.collect { renderType(it, typeParameterNames) }.join(' & ')
+        } else if (type instanceof UnionType) {
+            rendered = ((UnionType) type).elements.collect { renderType(it, typeParameterNames) }.join(' | ')
+        } else {
+            rendered = type.toString()
+        }
+
+        if (varArgs && rendered.endsWith('[]')) {
+            rendered = rendered[0..-3] + '...'
+        }
+        rendered
+    }
+
+    String eraseType(Type type, Set<String> typeParameterNames = Collections.emptySet()) {
+        if (type instanceof ArrayType) {
+            return eraseType(type.componentType, typeParameterNames) + '[]'
+        }
+        if (type instanceof ClassOrInterfaceType) {
+            def classType = (ClassOrInterfaceType) type
+            if (classType.scope.present) {
+                return rawScopedName(classType.scope.get(), typeParameterNames) + '.' + classType.nameAsString
+            }
+            return resolveSimpleName(classType.nameAsString, typeParameterNames)
+        }
+        type.toString()
+    }
+
+    String renderTypeParameter(TypeParameter typeParameter, Set<String> inheritedTypeParameters) {
+        def scopeTypeParameters = new LinkedHashSet<String>(inheritedTypeParameters)
+        scopeTypeParameters << typeParameter.nameAsString
+
+        def rendered = new StringBuilder(typeParameter.nameAsString)
+        if (!typeParameter.typeBound.empty) {
+            rendered << ' extends ' << typeParameter.typeBound.collect {
+                renderType(it, scopeTypeParameters)
+            }.join(' & ')
+        }
+        rendered.toString()
+    }
+
+    String resolveTypeName(String name, Set<String> typeParameterNames = Collections.emptySet()) {
+        if (!name) return name
+        if (PRIMITIVES.contains(name) || name == 'void' || typeParameterNames.contains(name)) return name
+
+        int dot = name.indexOf('.')
+        if (dot < 0) return resolveSimpleName(name, typeParameterNames)
+
+        String first = name.substring(0, dot)
+        if (!Character.isUpperCase(first.charAt(0)) && !explicitImports.containsKey(first)) {
+            return name
+        }
+
+        String resolvedFirst = resolveSimpleName(first, typeParameterNames)
+        resolvedFirst == first ? name : resolvedFirst + name.substring(dot)
+    }
+
+    private String renderClassOrInterfaceType(ClassOrInterfaceType type, Set<String> typeParameterNames) {
+        String name
+        if (type.scope.present) {
+            name = rawScopedName(type.scope.get(), typeParameterNames) + '.' + type.nameAsString
+        } else {
+            name = resolveSimpleName(type.nameAsString, typeParameterNames)
+        }
+
+        if (!type.typeArguments.present || type.typeArguments.get().empty) {
+            return name
+        }
+        name + '<' + type.typeArguments.get().collect { renderType(it, typeParameterNames) }.join(', ') + '>'
+    }
+
+    private String rawScopedName(ClassOrInterfaceType type, Set<String> typeParameterNames) {
+        if (type.scope.present) {
+            return rawScopedName(type.scope.get(), typeParameterNames) + '.' + type.nameAsString
+        }
+        resolveSimpleName(type.nameAsString, typeParameterNames)
+    }
+
+    private String resolveSimpleName(String name, Set<String> typeParameterNames) {
+        if (PRIMITIVES.contains(name) || name == 'void' || typeParameterNames.contains(name)) return name
+        if (explicitImports.containsKey(name)) return explicitImports[name]
+
+        if (packageName) {
+            String candidate = packageName + '.' + name
+            if (knownType(candidate)) return candidate
+        }
+
+        for (String importPrefix : wildcardImports) {
+            String candidate = importPrefix + '.' + name
+            if (knownType(candidate)) return candidate
+        }
+
+        for (String defaultImport : ResolveVisitor.DEFAULT_IMPORTS) {
+            String candidate = defaultImport + name
+            if (knownType(candidate)) return candidate
+        }
+
+        Set<String> matches = simpleNameIndex[name]
+        if (matches?.size() == 1) return matches.first()
+
+        name
+    }
+
+    private boolean knownType(String candidate) {
+        knownTypes.contains(candidate) || JavaExtensionSourceSet.tryLoad(candidate) != null
+    }
+}
+
+final class JavaExtensionMethod {
+    String name
+    String declaringClassName
+    boolean publicMethod
+    boolean staticMethod
+    boolean deprecated
+    String receiverTypeName
+    ReceiverTypeInfo receiverTypeInfo
+    String returnType
+    List<JavaExtensionParameter> parameters = []
+    List<String> typeParameters = []
+    List<String> exceptions = []
+    JavadocInfo javadoc
+}
+
+final class JavaExtensionParameter {
+    String type
+    String name
+    boolean varArgs
+}
+
+final class ReceiverTypeInfo {
+    String canonicalName
+    boolean primitive
+    boolean interfaceType
+}
+
+final class JavadocInfo {
+    String description = ''
+    List<JavadocTagInfo> tags = []
+
+    static JavadocInfo parse(JavadocComment comment) {
+        if (comment == null) return new JavadocInfo()
+
+        def descriptionLines = []
+        def tags = []
+        JavadocTagInfo currentTag = null
+
+        comment.content.readLines().collect {
+            it.replaceFirst(/^\s*\* ?/, '')
+        }.each { String line ->
+            if (line.startsWith('@')) {
+                def matcher = (line =~ /^@(\S+)\s*(.*)$/)
+                if (matcher.matches()) {
+                    currentTag = new JavadocTagInfo(name: matcher[0][1], value: matcher[0][2])
+                    tags << currentTag
+                }
+            } else if (currentTag != null) {
+                currentTag.value = currentTag.value ? currentTag.value + '\n' + line : line
+            } else {
+                descriptionLines << line
+            }
+        }
+
+        new JavadocInfo(
+                description: descriptionLines.join('\n').trim(),
+                tags: tags
+        )
+    }
+}
+
+final class JavadocTagInfo {
+    String name
+    String value
+}
+
+final class ParsedUnit {
+    File file
+    CompilationUnit compilationUnit
+}
diff --git a/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/MockSourceGenerator.groovy b/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/MockSourceGenerator.groovy
index daf011ed24..16dd2de705 100644
--- a/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/MockSourceGenerator.groovy
+++ b/subprojects/groovy-docgenerator/src/main/groovy/org/apache/groovy/docgenerator/MockSourceGenerator.groovy
@@ -18,11 +18,6 @@
  */
 package org.apache.groovy.docgenerator
 
-import com.thoughtworks.qdox.JavaProjectBuilder
-import com.thoughtworks.qdox.model.JavaClass
-import com.thoughtworks.qdox.model.JavaMethod
-import com.thoughtworks.qdox.model.JavaParameter
-import com.thoughtworks.qdox.model.JavaType
 import groovy.cli.internal.CliBuilderInternal
 import org.codehaus.groovy.runtime.DefaultGroovyMethods
 
@@ -77,29 +72,22 @@ class MockSourceGenerator {
      * Generates mock source files for all supported receiver types.
      */
     void generateAll() {
-        def builder = new JavaProjectBuilder()
-        sourceFiles.each {
-            if (it.exists()) {
-                builder.addSource(it.newReader())
-            }
+        def sourceSet = new JavaExtensionSourceSet()
+        sourceFiles.each { File sourceFile ->
+            sourceSet.addSource(sourceFile)
         }
 
         // Bucket methods by receiver FQCN (generics stripped, single-letter type
         // variables coerced to java.lang.Object[] as docgenerator historically did).
-        def byReceiver = new LinkedHashMap<String, List<JavaMethod>>()
-        def typeOf = new LinkedHashMap<String, JavaType>()
-        builder.sources.each { source ->
-            source.classes.each { JavaClass aClass ->
-                aClass.methods.each { JavaMethod method ->
-                    if (!method.public || !method.static) return
-                    if (method.parameters.empty) return
-                    if (method.annotations.any { it.type.fullyQualifiedName == 'java.lang.Deprecated' }) return
-                    def raw = method.parameters[0].type.toString()
-                    def fqcn = resolveJdkClassName(raw)
-                    byReceiver.computeIfAbsent(fqcn) { [] } << method
-                    typeOf.putIfAbsent(fqcn, method.parameters[0].type)
-                }
-            }
+        def byReceiver = new LinkedHashMap<String, List<JavaExtensionMethod>>()
+        def typeOf = new LinkedHashMap<String, ReceiverTypeInfo>()
+        sourceSet.methods.each { JavaExtensionMethod method ->
+            if (!method.publicMethod || !method.staticMethod) return
+            if (method.parameters.empty) return
+            if (method.deprecated) return
+            def fqcn = method.receiverTypeName
+            byReceiver.computeIfAbsent(fqcn) { [] } << method
+            typeOf.putIfAbsent(fqcn, method.receiverTypeInfo)
         }
 
         // Group nested receivers under their outer class so we emit one file per
@@ -110,23 +98,23 @@ class MockSourceGenerator {
             if (outer) {
                 // Make sure the outer exists as an owner even if it has no direct methods.
                 def parent = topLevelOwners.computeIfAbsent(outer) {
-                    new Owner(fqcn: outer, type: typeOf[outer])
+                    new Owner(fqcn: outer, typeInfo: typeOf[outer] ?: sourceSet.typeInfoForFqcn(outer))
                 }
-                parent.nested[fqcn] = new Owner(fqcn: fqcn, type: typeOf[fqcn], methods: methods)
+                parent.nested[fqcn] = new Owner(fqcn: fqcn, typeInfo: typeOf[fqcn], methods: methods)
             } else {
                 def owner = topLevelOwners.computeIfAbsent(fqcn) {
-                    new Owner(fqcn: fqcn, type: typeOf[fqcn])
+                    new Owner(fqcn: fqcn, typeInfo: typeOf[fqcn])
                 }
                 owner.methods.addAll(methods)
-                if (!owner.type) owner.type = typeOf[fqcn]
+                if (!owner.typeInfo) owner.typeInfo = typeOf[fqcn]
             }
         }
 
         def manifestEntries = []
         topLevelOwners.values().each { owner ->
-            def mockPkg = mockPackageFor(owner.fqcn, owner.type)
-            def mockName = mockClassNameFor(owner.fqcn, owner.type)
-            def displayPkg = displayPackageFor(owner.fqcn, owner.type)
+            def mockPkg = mockPackageFor(owner.fqcn, owner.typeInfo)
+            def mockName = mockClassNameFor(owner.fqcn, owner.typeInfo)
+            def displayPkg = displayPackageFor(owner.fqcn, owner.typeInfo)
             def displayName = displayNameFor(owner.fqcn)
             if (mockPkg != displayPkg || mockName != displayName) {
                 manifestEntries << "${mockPkg}\t${mockName}\t${displayPkg}\t${displayName}"
@@ -137,24 +125,24 @@ class MockSourceGenerator {
     }
 
     /** Where the generated HTML page should live after rewriting (historical URL). */
-    static String displayPackageFor(String fqcn, JavaType type) {
+    static String displayPackageFor(String fqcn, ReceiverTypeInfo typeInfo) {
         def base = stripArraySuffix(fqcn)
         if (base in PRIMITIVE_BASES) return 'primitives-and-primitive-arrays'
-        if (type?.isPrimitive()) return 'primitives-and-primitive-arrays'
+        if (typeInfo?.primitive) return 'primitives-and-primitive-arrays'
         def dot = base.lastIndexOf('.')
         dot < 0 ? '' : base[0..<dot]
     }
 
     private static class Owner {
         String fqcn
-        JavaType type
-        List<JavaMethod> methods = []
+        ReceiverTypeInfo typeInfo
+        List<JavaExtensionMethod> methods = []
         Map<String, Owner> nested = [:]
     }
 
     private void emitMock(Owner owner) {
-        def pkg = mockPackageFor(owner.fqcn, owner.type)
-        def mockName = mockClassNameFor(owner.fqcn, owner.type)
+        def pkg = mockPackageFor(owner.fqcn, owner.typeInfo)
+        def mockName = mockClassNameFor(owner.fqcn, owner.typeInfo)
         def displayName = displayNameFor(owner.fqcn)
 
         def dir = new File(outputDir, pkg.replace('.', '/'))
@@ -163,7 +151,7 @@ class MockSourceGenerator {
         def sb = new StringBuilder()
         sb << "package ${pkg};\n\n"
         appendClassJavadoc(sb, displayName, owner.fqcn, '')
-        def kw = classKeyword(owner.type)
+        def kw = classKeyword(owner.typeInfo)
         sb << "public ${kw} ${mockName} {\n"
         owner.methods.each { sb << serializeMethod(it, kw == 'interface', '    ') }
         owner.nested.values().each { nested ->
@@ -179,7 +167,7 @@ class MockSourceGenerator {
         def mockName = simpleMockName(owner.fqcn)
         sb << '\n'
         appendClassJavadoc(sb, displayName, owner.fqcn, indent)
-        def kw = classKeyword(owner.type)
+        def kw = classKeyword(owner.typeInfo)
         sb << "${indent}public static ${kw} ${mockName} {\n"
         owner.methods.each { sb << serializeMethod(it, kw == 'interface', indent + '    ') }
         sb << "${indent}}\n"
@@ -194,11 +182,11 @@ class MockSourceGenerator {
         sb << "${indent} */\n"
     }
 
-    private static String classKeyword(JavaType type) {
-        (type instanceof JavaClass && ((JavaClass) type).isInterface()) ? 'interface' : 'class'
+    private static String classKeyword(ReceiverTypeInfo typeInfo) {
+        typeInfo?.interfaceType ? 'interface' : 'class'
     }
 
-    private String serializeMethod(JavaMethod m, boolean ownerIsInterface, String indent) {
+    private String serializeMethod(JavaExtensionMethod m, boolean ownerIsInterface, String indent) {
         def sb = new StringBuilder()
         def javadoc = copyJavadoc(m)
         if (javadoc) {
@@ -207,18 +195,18 @@ class MockSourceGenerator {
             sb << "${indent} */\n"
         }
         sb << indent << 'public '
-        boolean isStatic = m.declaringClass.name.endsWith('StaticMethods') || m.declaringClass.name.endsWith('StaticExtensions')
+        boolean isStatic = m.declaringClassName.endsWith('StaticMethods') || m.declaringClassName.endsWith('StaticExtensions')
         if (isStatic) sb << 'static '
         if (m.typeParameters) {
-            sb << '<' << m.typeParameters.collect { it.toString() }.join(', ') << '> '
+            sb << '<' << m.typeParameters.join(', ') << '> '
         }
-        sb << (m.returns ? m.returns.genericCanonicalName : 'void') << ' '
+        sb << (m.returnType ?: 'void') << ' '
         sb << m.name << '('
         def params = isStatic ? m.parameters.toList() : (m.parameters.size() > 1 ? m.parameters.toList()[1..-1] : [])
-        sb << params.collect { JavaParameter p -> "${p.type.genericCanonicalName} ${p.name}" }.join(', ')
+        sb << params.collect { JavaExtensionParameter p -> "${p.type} ${p.name}" }.join(', ')
         sb << ')'
         if (m.exceptions) {
-            sb << ' throws ' << m.exceptions.collect { it.genericCanonicalName }.join(', ')
+            sb << ' throws ' << m.exceptions.join(', ')
         }
         if (ownerIsInterface && !isStatic) {
             sb << ';\n\n'
@@ -229,8 +217,8 @@ class MockSourceGenerator {
         sb.toString()
     }
 
-    private static String bodyFor(JavaMethod m) {
-        def rt = m.returns?.toString()
+    private static String bodyFor(JavaExtensionMethod m) {
+        def rt = m.returnType
         if (!rt || rt == 'void') return ''
         'throw new UnsupportedOperationException();'
     }
@@ -241,12 +229,12 @@ class MockSourceGenerator {
      * {@code {@link Recv#m(X,Y)}} so groovydoc's link resolver can resolve them
      * against the mock source tree.
      */
-    private static String copyJavadoc(JavaMethod m) {
-        def text = m.comment ?: ''
-        def allTags = m.tags
+    private static String copyJavadoc(JavaExtensionMethod m) {
+        def text = m.javadoc?.description ?: ''
+        def allTags = m.javadoc?.tags ?: []
         def paramTags = allTags.findAll { it.name == 'param' }
         def otherTags = allTags.findAll { it.name != 'param' }
-        boolean isStatic = m.declaringClass.name.endsWith('StaticMethods') || m.declaringClass.name.endsWith('StaticExtensions')
+        boolean isStatic = m.declaringClassName.endsWith('StaticMethods') || m.declaringClassName.endsWith('StaticExtensions')
         def remainingParams = isStatic ? paramTags : (paramTags.size() > 1 ? paramTags.drop(1) : [])
         def sb = new StringBuilder(rewriteLinks(text))
         if (sb.length() > 0) sb << '\n'
@@ -276,21 +264,21 @@ class MockSourceGenerator {
     }
 
     /** Package the mock file lives in, derived from the receiver's FQCN. */
-    static String mockPackageFor(String fqcn, JavaType type) {
+    static String mockPackageFor(String fqcn, ReceiverTypeInfo typeInfo) {
         def base = stripArraySuffix(fqcn)
         if (base in PRIMITIVE_BASES) return PRIMITIVES_PKG
-        if (type?.isPrimitive()) return PRIMITIVES_PKG
+        if (typeInfo?.primitive) return PRIMITIVES_PKG
         def dot = base.lastIndexOf('.')
         dot < 0 ? '' : base[0..<dot]
     }
 
     /** Legal Java identifier used as the mock class name for a top-level receiver. */
-    static String mockClassNameFor(String fqcn, JavaType type) {
+    static String mockClassNameFor(String fqcn, ReceiverTypeInfo typeInfo) {
         def base = stripArraySuffix(fqcn)
         def dims = arrayDimensions(fqcn)
         def simple = (base.lastIndexOf('.') < 0) ? base : base[(base.lastIndexOf('.') + 1)..-1]
         def name
-        if (base in PRIMITIVE_BASES || (type?.isPrimitive())) {
+        if (base in PRIMITIVE_BASES || (typeInfo?.primitive)) {
             name = 'Primitive' + simple.capitalize()
         } else {
             name = simple
diff --git a/subprojects/groovy-docgenerator/src/test/groovy/org/apache/groovy/docgenerator/MockSourceGeneratorTest.groovy b/subprojects/groovy-docgenerator/src/test/groovy/org/apache/groovy/docgenerator/MockSourceGeneratorTest.groovy
new file mode 100644
index 0000000000..31985126ae
--- /dev/null
+++ b/subprojects/groovy-docgenerator/src/test/groovy/org/apache/groovy/docgenerator/MockSourceGeneratorTest.groovy
@@ -0,0 +1,116 @@
+/*
+ *  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.apache.groovy.docgenerator
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+class MockSourceGeneratorTest {
+
+    @Test
+    void generatesMockSourcesFromJavaParserModel(@TempDir File tempDir) {
+        def sourceDir = new File(tempDir, 'src').tap { mkdirs() }
+        def outputDir = new File(tempDir, 'out').tap { mkdirs() }
+
+        def instanceExtensions = new File(sourceDir, 'demo/SampleMethods.java')
+        instanceExtensions.parentFile.mkdirs()
+        instanceExtensions.text = '''\
+            package demo;
+
+            import java.util.List;
+
+            public class SampleMethods {
+                /**
+                 * See {@link #append(List, String)}.
+                 *
+                 * @param self receiver
+                 * @param suffix suffix
+                 * @return appended value
+                 */
+                public static String append(List<String> self, String suffix) {
+                    return suffix;
+                }
+
+                @Deprecated
+                public static String skip(String self) {
+                    return self;
+                }
+            }
+            '''.stripIndent()
+
+        def staticExtensions = new File(sourceDir, 'demo/SampleStaticMethods.java')
+        staticExtensions.parentFile.mkdirs()
+        staticExtensions.text = '''\
+            package demo;
+
+            import java.io.IOException;
+            import java.util.Map;
+
+            public class SampleStaticMethods {
+                /**
+                 * Combines entries.
+                 *
+                 * @param self receiver
+                 * @param other other entry
+                 * @return combined entry
+                 * @throws IOException when broken
+                 */
+                public static <T extends Map.Entry<String, Integer>> T combine(Map.Entry<String, Integer> self, T other) throws IOException {
+                    return other;
+                }
+
+                /**
+                 * Counts values.
+                 *
+                 * @param self values
+                 * @return value count
+                 */
+                public static int count(int[] self) {
+                    return self.length;
+                }
+            }
+            '''.stripIndent()
+
+        new MockSourceGenerator([instanceExtensions, staticExtensions], outputDir).generateAll()
+
+        def listMock = new File(outputDir, 'java/util/List.java').text
+        assert listMock.contains('public interface List {')
+        assert listMock.contains('See {@link List#append(String)}.')
+        assert listMock.contains('@param suffix suffix')
+        assert !listMock.contains('@param self receiver')
+        assert listMock.contains('public java.lang.String append(java.lang.String suffix);')
+
+        def mapMock = new File(outputDir, 'java/util/Map.java').text
+        assert mapMock.contains('public interface Map {')
+        assert mapMock.contains('public static interface Entry {')
+        assert mapMock.contains('public static <T extends java.util.Map.Entry<java.lang.String, java.lang.Integer>> T combine(java.util.Map.Entry<java.lang.String, java.lang.Integer> self, T other) throws java.io.IOException { throw new UnsupportedOperationException(); }')
+        assert mapMock.contains('@param self receiver')
+        assert mapMock.contains('@param other other entry')
+
+        def primitiveMock = new File(outputDir, 'primitives/PrimitiveIntArray.java').text
+        assert primitiveMock.contains('@displayName int[]')
+        assert primitiveMock.contains('public class PrimitiveIntArray {')
+        assert primitiveMock.contains('public static int count(int[] self) { throw new UnsupportedOperationException(); }')
+
+        assert !new File(outputDir, 'java/lang/String.java').exists()
+
+        def manifest = new File(outputDir, MockSourceGenerator.MANIFEST_FILE).text
+        assert manifest.contains('primitives\tPrimitiveIntArray\tprimitives-and-primitive-arrays\tint[]')
+    }
+}
diff --git a/versions.properties b/versions.properties
index f97de0813f..e7fa310aeb 100644
--- a/versions.properties
+++ b/versions.properties
@@ -61,7 +61,6 @@ mavenResolverProvider=4.0.0-rc-5
 mavenResolverSupplier=2.0.18
 openbeans=1.0.2
 picocli=4.7.7
-qdox=2.2.0
 slf4j=2.0.18
 # running with Groovy 6 can be allowed with -Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true
 reactor=3.8.6