Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
*
* https://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.grails.doc.gradle

import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.TaskAction

/**
* A task that audits generated Groovydocs for malformed inner-class links, where a
* slash-separated 'phantom' path (e.g. {@code Outer/Inner.html}) was emitted instead
* of the dotted Groovydoc naming convention ({@code Outer.Inner.html}).
*
* @see GroovydocLinkAuditor
*/
abstract class AuditGroovydocLinksTask extends DefaultTask {

@InputDirectory
abstract DirectoryProperty getApiDocsDir()

@TaskAction
void auditLinks() {
File apiDir = apiDocsDir.get().asFile

List<String> violations = GroovydocLinkAuditor.findViolations(apiDir)

if (!violations.isEmpty()) {
violations.take(10).each { logger.error(it) }
if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more")
throw new GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.")
} else {
logger.lifecycle "No malformed Groovydoc links found in ${apiDir.absolutePath}"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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
*
* https://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.grails.doc.gradle

import java.nio.file.Files
import java.nio.file.Path
import java.util.regex.Matcher
import java.util.regex.Pattern

/**
* Scans generated Groovydoc HTML for malformed inner-class links, where a
* slash-separated path segment (produced for an inner class, e.g.
* {@code Outer/Inner.html}) should have been the dotted Groovydoc naming
* convention ({@code Outer.Inner.html}). Kept free of the Gradle API so it
* can be unit tested directly - {@code build-logic/docs-core} deliberately
* keeps Gradle classes off the test compile classpath.
*
* <p>This intentionally does not audit the navigation links (deprecated-list.html,
* help-doc.html, etc.): the bottom nav bar that {@code groovy-groovydoc} emits on
* package-summary pages omits the relative-root prefix that every other occurrence
* carries, which is a pre-existing quirk of that template rather than a content
* issue this repository can fix - checking for it would flag hundreds of instances
* of the same known, low-impact tool behavior on every doc build.</p>
*/
class GroovydocLinkAuditor {

// Inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html.
private static final Pattern INNER_CLASS_LINK_PATTERN =
Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/)

static List<String> findViolations(File apiDir) {
List<String> violations = []

apiDir.eachFileRecurse { File file ->
if (file.name.endsWith('.html')) {
violations.addAll(findViolationsInFile(file))
}
}

violations
}

private static List<String> findViolationsInFile(File file) {
List<String> violations = []
String content = file.text
Path currentPath = file.toPath().parent

Matcher innerMatcher = INNER_CLASS_LINK_PATTERN.matcher(content)
while (innerMatcher.find()) {
String relPath = innerMatcher.group(1)
String outer = innerMatcher.group(2)
String inner = innerMatcher.group(3)

Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize()

if (Files.exists(targetPath)) {
violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)}".toString()
}
}

violations
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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
*
* https://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.grails.doc.gradle

import spock.lang.Specification
import spock.lang.TempDir

class GroovydocLinkAuditorSpec extends Specification {

@TempDir
File apiDir

void "reports no violations for clean inner class links"() {
given:
writeHtml('index.html', """
<a href="deprecated-list.html">Deprecated</a>
<a href="help-doc.html">Help</a>
<a href='SomePackage.SomeClass.html'>SomeClass</a>
""")

expect:
GroovydocLinkAuditor.findViolations(apiDir).empty
}

void "flags an inner class link using a slash-separated path when the dotted file actually exists"() {
given: 'the real generated file uses the dotted Groovydoc naming convention'
writeHtml('Query.Order.Direction.html', '<p>Direction</p>')
and: 'another page links to it using a malformed slash-separated path'
writeHtml('index.html', "<a href='./Query/Order.Direction.html'>Direction</a>")

when:
List<String> violations = GroovydocLinkAuditor.findViolations(apiDir)

then:
violations.size() == 1
violations[0].contains('Query/Order.Direction.html')
}

void "does not flag a slash-separated inner class link when no dotted file exists to resolve to"() {
given: 'the referenced target was never generated, so this cannot be the known malformed-path case'
writeHtml('index.html', "<a href='./Query/Order.Direction.html'>Direction</a>")

expect:
GroovydocLinkAuditor.findViolations(apiDir).empty
}

private File writeHtml(String relativePath, String content) {
File file = new File(apiDir, relativePath)
file.parentFile.mkdirs()
file.text = content
file
}
}
60 changes: 47 additions & 13 deletions gradle/docs-dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,10 @@ dependencies {
}

String resolveProjectVersion(String artifact) {
String version = configurations.runtimeClasspath
.resolvedConfiguration
.resolvedArtifacts
.find {
it.moduleVersion.id.name == artifact
}?.moduleVersion?.id?.version
if (!version) {
return null
def component = configurations.runtimeClasspath.incoming.resolutionResult.allComponents.find {
it.moduleVersion?.name == artifact
}
version
return component?.moduleVersion?.version
}

def configureGroovyDoc = tasks.register('configureGroovyDoc') {
Expand All @@ -54,14 +48,54 @@ def configureGroovyDoc = tasks.register('configureGroovyDoc') {
if (testContainersVersion) {
links << [packages: 'org.testcontainers.', href: "https://javadoc.io/doc/org.testcontainers/testcontainers/${testContainersVersion}/"]
}
def springVersion = resolveProjectVersion('spring-core')
if (springVersion) {
links << [packages: 'org.springframework.core.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"]
}
// Groovydoc resolves 'links' first-match-wins in declaration order, so the more
// specific org.springframework.boot. entry must come before the broader
// org.springframework. entry or every Boot class link resolves against the
// Framework javadocs instead, where the Boot classes don't exist.
def springBootVersion = resolveProjectVersion('spring-boot')
if (springBootVersion) {
links << [packages: 'org.springframework.boot.', href: "https://docs.spring.io/spring-boot/docs/${springBootVersion}/api/"]
}
def springVersion = resolveProjectVersion('spring-core')
if (springVersion) {
links << [packages: 'org.springframework.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"]
}
def hibernateVersion = resolveProjectVersion('hibernate-core')
if (hibernateVersion) {
def shortVersion = hibernateVersion.split('\\.').take(2).join('.')
links << [packages: 'org.hibernate.', href: "https://docs.jboss.org/hibernate/orm/${shortVersion}/javadocs/"]
}
def jakartaValidationVersion = resolveProjectVersion('jakarta.validation-api')
if (jakartaValidationVersion) {
def specVersion = jakartaValidationVersion.split('\\.').take(2).join('.')
links << [packages: 'jakarta.validation.', href: "https://jakarta.ee/specifications/bean-validation/${specVersion}/apidocs/"]
}
def jakartaPersistenceVersion = resolveProjectVersion('jakarta.persistence-api')
if (jakartaPersistenceVersion) {
def specVersion = jakartaPersistenceVersion.split('\\.').take(2).join('.')
links << [packages: 'jakarta.persistence.', href: "https://jakarta.ee/specifications/persistence/${specVersion}/apidocs/"]
}
def jakartaServletVersion = resolveProjectVersion('jakarta.servlet-api')
if (jakartaServletVersion) {
def servletSpecVersion = jakartaServletVersion.split('\\.').take(2).join('.')
// The platform apidocs are versioned by Jakarta EE Platform release, not by the
// Servlet spec version, so the two can't be derived from one another directly.
def servletSpecToPlatformVersion = ['5.0': '9', '6.0': '10', '6.1': '11']
def platformVersion = servletSpecToPlatformVersion[servletSpecVersion]
if (platformVersion) {
links << [packages: 'jakarta.servlet.', href: "https://jakarta.ee/specifications/platform/${platformVersion}/apidocs/"]
}
}
// No external link is registered for org.grails.datastore./grails.gorm./org.grails.gorm.:
// since the data modules merged into grails-core in 7.0, those classes are generated
// directly in this repo's own aggregate Groovydoc and resolve locally. Mapping them to
// the older, standalone gorm.grails.org site would misdirect those in-repo links.
def groovyVersion = resolveProjectVersion('groovy')
if (groovyVersion) {
links << [packages: 'groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"]
links << [packages: 'org.apache.groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"]
links << [packages: 'org.codehaus.groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"]
}
if (it.ext.has('groovydocLinks')) {
links.addAll(it.ext.groovydocLinks as List<Map<String, String>>)
}
Expand Down
5 changes: 5 additions & 0 deletions grails-doc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import grails.doc.git.FetchTagsTask
import grails.doc.dropdown.CreateReleaseDropDownTask
import grails.doc.gradle.PublishGuideTask
import groovy.json.JsonSlurper
import org.grails.doc.gradle.AuditGroovydocLinksTask

import java.util.zip.ZipFile

Expand Down Expand Up @@ -125,6 +126,10 @@ combinedGroovydoc.configure { Groovydoc gdoc ->
gdoc.outputs.dir(gdoc.destinationDir)
}

def auditGroovydocLinks = tasks.register('auditGroovydocLinks', AuditGroovydocLinksTask) {
apiDocsDir = project.layout.dir(combinedGroovydoc.map { it.destinationDir })
}

String getVersion(String artifact) {
String version = configurations.runtimeClasspath
.resolvedConfiguration
Expand Down
Loading