7 changed files with 538 additions and 4 deletions
@ -0,0 +1,446 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2025 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed 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.springframework.boot.build.antora; |
||||||
|
|
||||||
|
import java.io.File; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.InputStream; |
||||||
|
import java.io.UncheckedIOException; |
||||||
|
import java.net.URL; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.nio.file.StandardOpenOption; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.Arrays; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.jar.JarEntry; |
||||||
|
import java.util.jar.JarFile; |
||||||
|
import java.util.regex.Matcher; |
||||||
|
import java.util.regex.Pattern; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
import java.util.stream.Stream; |
||||||
|
import java.util.stream.StreamSupport; |
||||||
|
import java.util.zip.ZipEntry; |
||||||
|
|
||||||
|
import org.gradle.api.DefaultTask; |
||||||
|
import org.gradle.api.file.DirectoryProperty; |
||||||
|
import org.gradle.api.file.FileCollection; |
||||||
|
import org.gradle.api.tasks.Classpath; |
||||||
|
import org.gradle.api.tasks.InputFiles; |
||||||
|
import org.gradle.api.tasks.Optional; |
||||||
|
import org.gradle.api.tasks.OutputDirectory; |
||||||
|
import org.gradle.api.tasks.TaskAction; |
||||||
|
import org.gradle.api.tasks.VerificationException; |
||||||
|
|
||||||
|
import org.springframework.asm.ClassReader; |
||||||
|
import org.springframework.asm.ClassVisitor; |
||||||
|
import org.springframework.asm.FieldVisitor; |
||||||
|
import org.springframework.asm.MethodVisitor; |
||||||
|
import org.springframework.asm.SpringAsmInfo; |
||||||
|
import org.springframework.asm.Type; |
||||||
|
import org.springframework.util.function.ThrowingConsumer; |
||||||
|
|
||||||
|
/** |
||||||
|
* A task to check {@code javadoc:[]} macros in Antora source files. |
||||||
|
* |
||||||
|
* @author Andy Wilkinson |
||||||
|
*/ |
||||||
|
public abstract class CheckJavadocMacros extends DefaultTask { |
||||||
|
|
||||||
|
private static final Pattern JAVADOC_MACRO_PATTERN = Pattern.compile("javadoc:(.*?)\\[(.*?)\\]"); |
||||||
|
|
||||||
|
private final Path projectRoot; |
||||||
|
|
||||||
|
private FileCollection source; |
||||||
|
|
||||||
|
private FileCollection classpath; |
||||||
|
|
||||||
|
public CheckJavadocMacros() { |
||||||
|
this.projectRoot = getProject().getRootDir().toPath(); |
||||||
|
} |
||||||
|
|
||||||
|
@InputFiles |
||||||
|
public FileCollection getSource() { |
||||||
|
return this.source; |
||||||
|
} |
||||||
|
|
||||||
|
public void setSource(FileCollection source) { |
||||||
|
this.source = source; |
||||||
|
} |
||||||
|
|
||||||
|
@Optional |
||||||
|
@Classpath |
||||||
|
public FileCollection getClasspath() { |
||||||
|
return this.classpath; |
||||||
|
} |
||||||
|
|
||||||
|
public void setClasspath(FileCollection classpath) { |
||||||
|
this.classpath = classpath; |
||||||
|
} |
||||||
|
|
||||||
|
@OutputDirectory |
||||||
|
public abstract DirectoryProperty getOutputDirectory(); |
||||||
|
|
||||||
|
@TaskAction |
||||||
|
void checkJavadocMacros() { |
||||||
|
Set<String> availableClasses = indexClasspath(); |
||||||
|
List<String> problems = new ArrayList<>(); |
||||||
|
this.source.getAsFileTree() |
||||||
|
.filter((file) -> file.getName().endsWith(".adoc")) |
||||||
|
.forEach((file) -> problems.addAll(checkJavadocMacros(file, availableClasses))); |
||||||
|
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); |
||||||
|
writeReport(problems, outputFile); |
||||||
|
if (!problems.isEmpty()) { |
||||||
|
throw new VerificationException("Javadoc macro check failed. See '%s' for details".formatted(outputFile)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private Set<String> indexClasspath() { |
||||||
|
Set<String> availableClasses = StreamSupport.stream(this.classpath.spliterator(), false).flatMap((root) -> { |
||||||
|
if (root.isFile()) { |
||||||
|
try (JarFile jar = new JarFile(root)) { |
||||||
|
return jar.stream() |
||||||
|
.map(JarEntry::getName) |
||||||
|
.filter((entryName) -> entryName.endsWith(".class")) |
||||||
|
.map((className) -> { |
||||||
|
if (className.startsWith("META-INF/versions/")) { |
||||||
|
className = className.substring("META-INF/versions/".length()); |
||||||
|
} |
||||||
|
className = className.substring(0, className.length() - ".class".length()); |
||||||
|
className = className.replace('/', '.'); |
||||||
|
return className; |
||||||
|
}) |
||||||
|
.toList() |
||||||
|
.stream(); |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
throw new UncheckedIOException(ex); |
||||||
|
} |
||||||
|
} |
||||||
|
return Stream.empty(); |
||||||
|
}).collect(Collectors.toSet()); |
||||||
|
return availableClasses; |
||||||
|
} |
||||||
|
|
||||||
|
private List<String> checkJavadocMacros(File adocFile, Set<String> availableClasses) { |
||||||
|
List<String> problems = new ArrayList<>(); |
||||||
|
List<JavadocMacro> macros = JavadocMacro.parse(adocFile); |
||||||
|
for (JavadocMacro macro : macros) { |
||||||
|
if (!classIsAvailable(macro.className.name, availableClasses)) { |
||||||
|
problems.add(this.projectRoot.relativize(macro.className.origin.file.toPath()) + ":" |
||||||
|
+ macro.className.origin.line + ":" + macro.className.origin.column + ": class " |
||||||
|
+ macro.className.name + " does not exist."); |
||||||
|
} |
||||||
|
else { |
||||||
|
JavadocAnchor anchor = macro.anchor; |
||||||
|
if (anchor != null) { |
||||||
|
if (anchor instanceof MethodAnchor methodAnchor) { |
||||||
|
MethodMatcher methodMatcher = new MethodMatcher(methodAnchor); |
||||||
|
inputStreamOf(macro.className.name, (stream) -> { |
||||||
|
ClassReader reader = new ClassReader(stream); |
||||||
|
reader.accept(methodMatcher, 0); |
||||||
|
}); |
||||||
|
if (!methodMatcher.matched) { |
||||||
|
problems.add(this.projectRoot.relativize(macro.anchor.origin.file.toPath()) + ":" |
||||||
|
+ macro.anchor.origin.line + ":" + methodAnchor.origin().column + ": method " |
||||||
|
+ methodAnchor + " does not exist"); |
||||||
|
} |
||||||
|
} |
||||||
|
else if (anchor instanceof FieldAnchor fieldAnchor) { |
||||||
|
FieldMatcher fieldMatcher = new FieldMatcher(fieldAnchor); |
||||||
|
inputStreamOf(macro.className.name, (stream) -> { |
||||||
|
ClassReader reader = new ClassReader(stream); |
||||||
|
reader.accept(fieldMatcher, 0); |
||||||
|
}); |
||||||
|
if (!fieldMatcher.matched) { |
||||||
|
problems.add(this.projectRoot.relativize(macro.anchor.origin.file.toPath()) + ":" |
||||||
|
+ macro.anchor.origin.line + ":" + fieldAnchor.origin().column + ": field " |
||||||
|
+ fieldAnchor.name + " does not exist"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return problems; |
||||||
|
} |
||||||
|
|
||||||
|
private boolean classIsAvailable(String className, Set<String> availableClasses) { |
||||||
|
if (availableClasses.contains(className)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
if (className.startsWith("java.") || className.startsWith("javax.")) { |
||||||
|
return jdkResourceForClass(className) != null; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
private URL jdkResourceForClass(String className) { |
||||||
|
return getClass().getClassLoader().getResource(className.replace(".", "/") + ".class"); |
||||||
|
} |
||||||
|
|
||||||
|
private void inputStreamOf(String className, ThrowingConsumer<InputStream> streamHandler) { |
||||||
|
for (File root : this.classpath) { |
||||||
|
if (root.isFile()) { |
||||||
|
try (JarFile jar = new JarFile(root)) { |
||||||
|
ZipEntry entry = jar.getEntry(className.replace(".", "/") + ".class"); |
||||||
|
if (entry != null) { |
||||||
|
try (InputStream stream = jar.getInputStream(entry)) { |
||||||
|
streamHandler.accept(stream); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
throw new UncheckedIOException(ex); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
URL resource = jdkResourceForClass(className); |
||||||
|
if (resource != null) { |
||||||
|
try (InputStream stream = resource.openStream()) { |
||||||
|
streamHandler.accept(stream); |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
throw new UncheckedIOException(ex); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void writeReport(List<String> problems, File outputFile) { |
||||||
|
outputFile.getParentFile().mkdirs(); |
||||||
|
StringBuilder report = new StringBuilder(); |
||||||
|
if (!problems.isEmpty()) { |
||||||
|
if (problems.size() == 1) { |
||||||
|
report.append("Found 1 javadoc macro problem:%n".formatted()); |
||||||
|
} |
||||||
|
else { |
||||||
|
report.append("Found %d javadoc macro problems:%n".formatted(problems.size())); |
||||||
|
} |
||||||
|
problems.forEach((problem) -> report.append("%s%n".formatted(problem))); |
||||||
|
} |
||||||
|
try { |
||||||
|
Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, |
||||||
|
StandardOpenOption.TRUNCATE_EXISTING); |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
throw new UncheckedIOException(ex); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static final class JavadocMacro { |
||||||
|
|
||||||
|
private final ClassName className; |
||||||
|
|
||||||
|
private final JavadocAnchor anchor; |
||||||
|
|
||||||
|
private JavadocMacro(ClassName className, JavadocAnchor anchor) { |
||||||
|
this.className = className; |
||||||
|
this.anchor = anchor; |
||||||
|
} |
||||||
|
|
||||||
|
private static List<JavadocMacro> parse(File adocFile) { |
||||||
|
List<JavadocMacro> macros = new ArrayList<>(); |
||||||
|
try { |
||||||
|
Path adocFilePath = adocFile.toPath(); |
||||||
|
List<String> lines = Files.readAllLines(adocFilePath); |
||||||
|
for (int i = 0; i < lines.size(); i++) { |
||||||
|
Matcher matcher = JAVADOC_MACRO_PATTERN.matcher(lines.get(i)); |
||||||
|
while (matcher.find()) { |
||||||
|
Origin classNameOrigin = new Origin(adocFile, i + 1, matcher.start(1) + 1); |
||||||
|
String target = matcher.group(1); |
||||||
|
String className = target; |
||||||
|
int endOfUrlAttribute = className.indexOf("}/"); |
||||||
|
if (endOfUrlAttribute != -1) { |
||||||
|
className = className.substring(endOfUrlAttribute + 2); |
||||||
|
} |
||||||
|
JavadocAnchor anchor = null; |
||||||
|
int anchorIndex = className.indexOf("#"); |
||||||
|
if (anchorIndex != -1) { |
||||||
|
anchor = JavadocAnchor.of(className.substring(anchorIndex + 1), new Origin(adocFile, |
||||||
|
classNameOrigin.line(), classNameOrigin.column + anchorIndex + 1)); |
||||||
|
className = className.substring(0, anchorIndex); |
||||||
|
} |
||||||
|
macros.add(new JavadocMacro(new ClassName(classNameOrigin, className), anchor)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
throw new UncheckedIOException(ex); |
||||||
|
} |
||||||
|
return macros; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class ClassName { |
||||||
|
|
||||||
|
private final Origin origin; |
||||||
|
|
||||||
|
private final String name; |
||||||
|
|
||||||
|
private ClassName(Origin origin, String name) { |
||||||
|
this.origin = origin; |
||||||
|
this.name = name; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private record Origin(File file, int line, int column) { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private abstract static class JavadocAnchor { |
||||||
|
|
||||||
|
private final Origin origin; |
||||||
|
|
||||||
|
protected JavadocAnchor(Origin origin) { |
||||||
|
this.origin = origin; |
||||||
|
} |
||||||
|
|
||||||
|
Origin origin() { |
||||||
|
return this.origin; |
||||||
|
} |
||||||
|
|
||||||
|
private static JavadocAnchor of(String anchor, Origin origin) { |
||||||
|
JavadocAnchor javadocAnchor = WellKnownAnchor.of(anchor, origin); |
||||||
|
if (javadocAnchor == null) { |
||||||
|
javadocAnchor = MethodAnchor.of(anchor, origin); |
||||||
|
} |
||||||
|
if (javadocAnchor == null) { |
||||||
|
javadocAnchor = FieldAnchor.of(anchor, origin); |
||||||
|
} |
||||||
|
return javadocAnchor; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class WellKnownAnchor extends JavadocAnchor { |
||||||
|
|
||||||
|
private WellKnownAnchor(Origin origin) { |
||||||
|
super(origin); |
||||||
|
} |
||||||
|
|
||||||
|
private static WellKnownAnchor of(String anchor, Origin origin) { |
||||||
|
if (anchor.equals("enum-constant-summary")) { |
||||||
|
return new WellKnownAnchor(origin); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class MethodAnchor extends JavadocAnchor { |
||||||
|
|
||||||
|
private final String name; |
||||||
|
|
||||||
|
private final List<String> arguments; |
||||||
|
|
||||||
|
private MethodAnchor(String name, List<String> arguments, Origin origin) { |
||||||
|
super(origin); |
||||||
|
this.name = name; |
||||||
|
this.arguments = arguments; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String toString() { |
||||||
|
return this.name + "(" + String.join(", ", this.arguments + ")"); |
||||||
|
} |
||||||
|
|
||||||
|
static MethodAnchor of(String anchor, Origin origin) { |
||||||
|
if (!anchor.contains("(")) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
int openingIndex = anchor.indexOf('('); |
||||||
|
String name = anchor.substring(0, openingIndex); |
||||||
|
List<String> arguments = Stream.of(anchor.substring(openingIndex + 1, anchor.length() - 1).split(",")) |
||||||
|
.map(String::trim) |
||||||
|
.map((argument) -> argument.endsWith("...") ? argument.replace("...", "[]") : argument) |
||||||
|
.toList(); |
||||||
|
return new MethodAnchor(name, arguments, origin); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class FieldAnchor extends JavadocAnchor { |
||||||
|
|
||||||
|
private final String name; |
||||||
|
|
||||||
|
private FieldAnchor(String name, Origin origin) { |
||||||
|
super(origin); |
||||||
|
this.name = name; |
||||||
|
} |
||||||
|
|
||||||
|
static FieldAnchor of(String anchor, Origin origin) { |
||||||
|
return new FieldAnchor(anchor, origin); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class MethodMatcher extends ClassVisitor { |
||||||
|
|
||||||
|
private final MethodAnchor methodAnchor; |
||||||
|
|
||||||
|
private boolean matched = false; |
||||||
|
|
||||||
|
private MethodMatcher(MethodAnchor methodAnchor) { |
||||||
|
super(SpringAsmInfo.ASM_VERSION); |
||||||
|
this.methodAnchor = methodAnchor; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, |
||||||
|
String[] exceptions) { |
||||||
|
if (!this.matched && name.equals(this.methodAnchor.name)) { |
||||||
|
Type type = Type.getType(descriptor); |
||||||
|
if (type.getArgumentCount() == this.methodAnchor.arguments.size()) { |
||||||
|
List<String> argumentTypeNames = Arrays.asList(type.getArgumentTypes()) |
||||||
|
.stream() |
||||||
|
.map(Type::getClassName) |
||||||
|
.toList(); |
||||||
|
if (argumentTypeNames.equals(this.methodAnchor.arguments)) { |
||||||
|
this.matched = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private static final class FieldMatcher extends ClassVisitor { |
||||||
|
|
||||||
|
private final FieldAnchor fieldAnchor; |
||||||
|
|
||||||
|
private boolean matched = false; |
||||||
|
|
||||||
|
private FieldMatcher(FieldAnchor fieldAnchor) { |
||||||
|
super(SpringAsmInfo.ASM_VERSION); |
||||||
|
this.fieldAnchor = fieldAnchor; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { |
||||||
|
if (!this.matched && name.equals(this.fieldAnchor.name)) { |
||||||
|
this.matched = true; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue