You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by pk...@apache.org on 2024/02/02 16:00:35 UTC

(logging-log4j-tools) 01/01: Add annotation processor to generate documentation

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

pkarwasz pushed a commit to branch processor
in repository https://gitbox.apache.org/repos/asf/logging-log4j-tools.git

commit 189b5c6324938ecdfad5d72af38564639258ba8e
Author: Piotr P. Karwasz <pi...@karwasz.org>
AuthorDate: Fri Feb 2 16:50:03 2024 +0100

    Add annotation processor to generate documentation
    
    We add a documentation-generating annotation processor.
    
    By default it generates a `plugins.xml` descriptor in the
    `META-INF/log4j` directory of the output folder.
    
    Currently it supports:
    
     * Log4j Plugins 3.x annotations **only**,
     * Both factory methods and builders.
    
    Closes apache/logging-log4j2#1956.
---
 .../log4j/docgen/processor/DocGenProcessor.java    | 446 +++++++++++++++++++++
 .../src/test/it/example/AbstractAppender.java      |  23 ++
 log4j-docgen/src/test/it/example/Appender.java     |  23 ++
 log4j-docgen/src/test/it/example/BaseAppender.java |  23 ++
 log4j-docgen/src/test/it/example/Filter.java       |  23 ++
 log4j-docgen/src/test/it/example/Layout.java       |  23 ++
 log4j-docgen/src/test/it/example/MyAppender.java   | 141 +++++++
 log4j-docgen/src/test/it/example/MyEnum.java       |  39 ++
 log4j-docgen/src/test/it/example/MyOldLayout.java  |  62 +++
 .../docgen/processor/DocGenProcessorTest.java      |  98 +++++
 .../expected/processor/META-INF/log4j/plugins.xml  | 173 ++++++++
 11 files changed, 1074 insertions(+)

diff --git a/log4j-docgen/src/main/java/org/apache/logging/log4j/docgen/processor/DocGenProcessor.java b/log4j-docgen/src/main/java/org/apache/logging/log4j/docgen/processor/DocGenProcessor.java
new file mode 100644
index 0000000..0bade93
--- /dev/null
+++ b/log4j-docgen/src/main/java/org/apache/logging/log4j/docgen/processor/DocGenProcessor.java
@@ -0,0 +1,446 @@
+/*
+ * 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.logging.log4j.docgen.processor;
+
+import static org.apache.commons.lang3.StringUtils.defaultIfEmpty;
+import static org.apache.commons.lang3.StringUtils.defaultString;
+
+import com.sun.source.doctree.DocCommentTree;
+import com.sun.source.doctree.DocTree;
+import com.sun.source.doctree.ParamTree;
+import com.sun.source.util.DocTrees;
+import java.io.IOException;
+import java.io.Writer;
+import java.lang.annotation.Annotation;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.annotation.processing.SupportedSourceVersion;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.QualifiedNameable;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.ArrayType;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.PrimitiveType;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.type.TypeVisitor;
+import javax.lang.model.util.Elements;
+import javax.lang.model.util.SimpleTypeVisitor8;
+import javax.lang.model.util.Types;
+import javax.tools.Diagnostic;
+import javax.tools.FileObject;
+import javax.tools.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.docgen.AbstractType;
+import org.apache.logging.log4j.docgen.Description;
+import org.apache.logging.log4j.docgen.PluginAttribute;
+import org.apache.logging.log4j.docgen.PluginElement;
+import org.apache.logging.log4j.docgen.PluginSet;
+import org.apache.logging.log4j.docgen.PluginType;
+import org.apache.logging.log4j.docgen.ScalarType;
+import org.apache.logging.log4j.docgen.ScalarValue;
+import org.apache.logging.log4j.docgen.Type;
+import org.apache.logging.log4j.docgen.io.stax.PluginBundleStaxWriter;
+
+@SupportedAnnotationTypes("org.apache.logging.log4j.plugins.*")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+public class DocGenProcessor extends AbstractProcessor {
+
+    private static final String MULTIPLICITY_UNBOUNDED = "*";
+    private static final TypeVisitor<String, Void> CLASS_NAME_VISITOR = new ClassNameVisitor();
+    private static final CharSequence[] GETTER_SETTER_PREFIXES = {"get", "is", "set"};
+    private static final List<Class<? extends Annotation>> PROPERTY_ANNOTATION_TYPES = List.of(
+            org.apache.logging.log4j.plugins.PluginBuilderAttribute.class,
+            org.apache.logging.log4j.plugins.PluginAttribute.class,
+            org.apache.logging.log4j.plugins.PluginElement.class);
+    private static final List<Class<? extends Annotation>> FACTORY_ANNOTATION_TYPES = List.of(
+            org.apache.logging.log4j.plugins.Factory.class, org.apache.logging.log4j.plugins.PluginFactory.class);
+
+    // Plugins
+    private final Map<String, PluginType> pluginTypes = new TreeMap<>();
+    // Other interfaces
+    private final Map<String, DeclaredType> abstractTypes = new TreeMap<>();
+    // Scalar types
+    private final Map<String, DeclaredType> scalarTypes = new TreeMap<>();
+
+    private AsciidocConverter converter;
+    private DocTrees docTrees;
+    private Elements elements;
+    private Types types;
+    private Messager messager;
+
+    @Override
+    public synchronized void init(final ProcessingEnvironment processingEnv) {
+        super.init(processingEnv);
+        docTrees = DocTrees.instance(processingEnv);
+        converter = new AsciidocConverter(docTrees);
+        elements = processingEnv.getElementUtils();
+        types = processingEnv.getTypeUtils();
+        messager = processingEnv.getMessager();
+    }
+
+    @Override
+    public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
+        final PluginSet set = new PluginSet();
+        // First step: document plugins
+        roundEnv.getElementsAnnotatedWith(org.apache.logging.log4j.plugins.Plugin.class)
+                .forEach(element -> {
+                    final PluginType pluginType = new PluginType();
+                    final org.apache.logging.log4j.plugins.Plugin pluginAnnotation =
+                            element.getAnnotation(org.apache.logging.log4j.plugins.Plugin.class);
+                    processPlugin((TypeElement) element, pluginAnnotation, pluginType);
+                    pluginTypes.put(pluginType.getClassName(), pluginType);
+                });
+        pluginTypes.values().forEach(set::addPlugin);
+        // Second step: document abstract types
+        abstractTypes.values().forEach(type -> {
+            final AbstractType abstractType = new AbstractType();
+            processAbstractType(type, abstractType);
+            if (!abstractType.getDescription().getText().isEmpty()) {
+                set.addAbstractType(abstractType);
+            }
+        });
+        // Second step: document scalars
+        scalarTypes.values().forEach(type -> {
+            final ScalarType scalarType = new ScalarType();
+            processScalarType(type, scalarType);
+            set.addScalar(scalarType);
+        });
+        // Write the result file
+        if (roundEnv.processingOver()) {
+            try {
+                final FileObject output = processingEnv
+                        .getFiler()
+                        .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/log4j/plugins.xml");
+
+                try (final Writer writer = output.openWriter()) {
+                    new PluginBundleStaxWriter().write(writer, set);
+                }
+            } catch (final IOException | XMLStreamException e) {
+                messager.printMessage(
+                        Diagnostic.Kind.ERROR,
+                        "An error occurred while writing to `META-INF/log4j/plugins.xml`: " + e.getMessage());
+            }
+        }
+        return false;
+    }
+
+    private void processType(final DeclaredType type, final Type docgenType) {
+        // Class name
+        docgenType.setClassName(type.accept(CLASS_NAME_VISITOR, null));
+        // Description
+        docgenType.setDescription(createDescription(type.asElement(), null));
+    }
+
+    private void processAbstractType(final DeclaredType type, final AbstractType abstractType) {
+        processType(type, abstractType);
+    }
+
+    private void processScalarType(final DeclaredType type, final ScalarType scalarType) {
+        processType(type, scalarType);
+        final TypeMirror enumType =
+                types.erasure(elements.getTypeElement("java.lang.Enum").asType());
+        if (types.isSubtype(type, enumType)) {
+            for (final Element element : elements.getAllMembers((TypeElement) type.asElement())) {
+                if (element instanceof final VariableElement field
+                        && field.getModifiers().contains(Modifier.STATIC)
+                        && types.isSameType(field.asType(), type)) {
+                    final ScalarValue value = new ScalarValue();
+                    value.setDescription(createDescription(element, null));
+                    value.setName(field.getSimpleName().toString());
+                    scalarType.addValue(value);
+                }
+            }
+        }
+    }
+
+    private Map<String, String> processParameterDescriptions(final Element element) {
+        final Map<String, String> descriptions = new HashMap<>();
+        final DocCommentTree docCommentTree = docTrees.getDocCommentTree(element);
+        if (docCommentTree != null) {
+            for (final DocTree tree : docCommentTree.getBlockTags()) {
+                if (tree.getKind() == DocTree.Kind.PARAM) {
+                    final ParamTree paramTree = (ParamTree) tree;
+                    final String name = paramTree.getName().getName().toString();
+                    descriptions.put(name, defaultString(converter.toAsciiDoc(paramTree)));
+                }
+            }
+        }
+        return descriptions;
+    }
+
+    private void processPlugin(
+            final TypeElement element,
+            final org.apache.logging.log4j.plugins.Plugin pluginAnnotation,
+            final PluginType pluginType) {
+        final DeclaredType type = (DeclaredType) element.asType();
+        processAbstractType(type, pluginType);
+        // Name
+        pluginType.setName(getPluginName(element, pluginAnnotation));
+        // Namespace
+        final org.apache.logging.log4j.plugins.Namespace namespace =
+                element.getAnnotation(org.apache.logging.log4j.plugins.Namespace.class);
+        pluginType.setNamespace(namespace != null ? namespace.value() : "Core");
+        // Supertypes
+        registerSupertypes(type).forEach(pluginType::addSupertype);
+        // Plugin factory
+        for (final Element member : elements.getAllMembers(element)) {
+            if (!findAllAnnotationsOnMember(member, FACTORY_ANNOTATION_TYPES).isEmpty()) {
+                final ExecutableElement executable = (ExecutableElement) member;
+                final Map<String, String> descriptions = processParameterDescriptions(executable);
+                final List<? extends VariableElement> parameters = executable.getParameters();
+                if (parameters.isEmpty()) {
+                    // We have a builder
+                    final TypeMirror returnType = executable.getReturnType();
+                    if (returnType instanceof DeclaredType) {
+                        processProperties(
+                                elements.getAllMembers((TypeElement) ((DeclaredType) returnType).asElement()),
+                                descriptions,
+                                pluginType);
+                    } else {
+                        messager.printMessage(
+                                Diagnostic.Kind.WARNING,
+                                "The return type of a @PluginFactory annotated method should be a concrete class.",
+                                member);
+                    }
+                } else {
+                    // Old style factory method
+                    processProperties(parameters, descriptions, pluginType);
+                }
+            }
+        }
+    }
+
+    private void processProperties(
+            final Iterable<? extends Element> members,
+            final Map<? super String, String> descriptions,
+            final PluginType pluginType) {
+        final Collection<PluginAttribute> pluginAttributes =
+                new TreeSet<>(Comparator.comparing(PluginAttribute::getName));
+        final Collection<PluginElement> pluginElements = new TreeSet<>(Comparator.comparing(PluginElement::getType));
+        // Gather documentation, which can be on any member.
+        for (final Element member : members) {
+            final String name = getMemberName(member);
+            final String asciidoc = converter.toAsciiDoc(member);
+            descriptions.compute(name, (key, value) -> Stream.of(value, asciidoc)
+                    .filter(StringUtils::isNotEmpty)
+                    .collect(Collectors.joining("\n")));
+        }
+        // Creates attributes and elements
+        for (final Element member : members) {
+            final String description = descriptions.get(getMemberName(member));
+            for (final Annotation annotation : findAllAnnotationsOnMember(member, PROPERTY_ANNOTATION_TYPES)) {
+                if (annotation instanceof final org.apache.logging.log4j.plugins.PluginBuilderAttribute attribute) {
+                    pluginAttributes.add(createPluginAttribute(member, description, attribute.value()));
+                }
+                if (annotation instanceof final org.apache.logging.log4j.plugins.PluginAttribute attribute) {
+                    pluginAttributes.add(createPluginAttribute(member, description, attribute.value()));
+                }
+                if (annotation instanceof org.apache.logging.log4j.plugins.PluginElement) {
+                    pluginElements.add(createPluginElement(member, description));
+                }
+            }
+        }
+        pluginAttributes.forEach(pluginType::addAttribute);
+        pluginElements.forEach(pluginType::addElement);
+    }
+
+    private Description createDescription(final String asciidoc) {
+        final Description description = new Description();
+        description.setText(StringUtils.stripToEmpty(asciidoc));
+        return description;
+    }
+
+    private Description createDescription(final Element element, final String fallback) {
+        return createDescription(defaultIfEmpty(converter.toAsciiDoc(element), defaultString(fallback)));
+    }
+
+    private PluginAttribute createPluginAttribute(
+            final Element element, final String description, final String specifiedName) {
+        final PluginAttribute attribute = new PluginAttribute();
+        // Name
+        attribute.setName(specifiedName.isEmpty() ? getMemberName(element) : specifiedName);
+        // Type
+        final TypeMirror type = getMemberType(element);
+        final String className = type.accept(CLASS_NAME_VISITOR, null);
+        if (type instanceof DeclaredType && !"java.lang.String".equals(type.accept(CLASS_NAME_VISITOR, null))) {
+            scalarTypes.putIfAbsent(className, (DeclaredType) type);
+        }
+        attribute.setType(className);
+        // Description
+        attribute.setDescription(createDescription(element, description));
+        // Required
+        if (element.getAnnotation(org.apache.logging.log4j.plugins.validation.constraints.Required.class) != null) {
+            attribute.setRequired(true);
+        }
+        // Default value
+        final Object defaultValue = element instanceof final VariableElement field ? field.getConstantValue() : null;
+        if (defaultValue != null) {
+            attribute.setDefaultValue(elements.getConstantExpression(defaultValue));
+        }
+        // TODO: add the value of the property used, when we add it to the annotation.
+        return attribute;
+    }
+
+    private PluginElement createPluginElement(final Element element, final String description) {
+        final PluginElement pluginElement = new PluginElement();
+        // Type and multiplicity
+        final TypeMirror elementType = getMemberType(element);
+        if (elementType instanceof final ArrayType arrayType) {
+            pluginElement.setType(arrayType.getComponentType().accept(CLASS_NAME_VISITOR, null));
+            pluginElement.setMultiplicity(MULTIPLICITY_UNBOUNDED);
+        } else {
+            pluginElement.setType(elementType.accept(CLASS_NAME_VISITOR, null));
+        }
+        // Required
+        if (element.getAnnotation(org.apache.logging.log4j.plugins.validation.constraints.Required.class) != null) {
+            pluginElement.setRequired(true);
+        }
+        // Description
+        pluginElement.setDescription(createDescription(element, description));
+        return pluginElement;
+    }
+
+    /**
+     * Register all the supertypes of the given type for doc processing.
+     * @param type a plugin type,
+     * @return the set of FQCN of all supertypes.
+     */
+    private Set<String> registerSupertypes(final TypeMirror type) {
+        return registerSupertypes(type, new TreeSet<>());
+    }
+
+    private Set<String> registerSupertypes(final TypeMirror type, final Set<String> visited) {
+        for (final TypeMirror supertype : types.directSupertypes(type)) {
+            final String className = supertype.accept(CLASS_NAME_VISITOR, null);
+            if (visited.add(className)) {
+                abstractTypes.putIfAbsent(className, (DeclaredType) supertype);
+                registerSupertypes(supertype, visited);
+            }
+        }
+        return visited;
+    }
+
+    private String getPluginName(final Element element, final org.apache.logging.log4j.plugins.Plugin annotation) {
+        final String value = annotation.value();
+        return value.isEmpty() ? element.getSimpleName().toString() : value;
+    }
+
+    private Collection<? extends Annotation> findAllAnnotationsOnMember(
+            final Element element, final Collection<Class<? extends Annotation>> annotationTypes) {
+        final Collection<Annotation> annotations = new HashSet<>();
+        findAllAnnotationsOnMember(element, annotationTypes, annotations);
+        return annotations;
+    }
+
+    private void findAllAnnotationsOnMember(
+            final Element element,
+            final Collection<Class<? extends Annotation>> annotationTypes,
+            final Collection<? super Annotation> annotations) {
+        for (final Class<? extends Annotation> annotationType : annotationTypes) {
+            final Annotation annotation = element.getAnnotation(annotationType);
+            if (annotation != null) {
+                annotations.add(annotation);
+            }
+        }
+        // Annotated method parameter
+        if (element.getKind() == ElementKind.METHOD) {
+            final String name = element.getSimpleName().toString();
+            final List<? extends VariableElement> parameters = ((ExecutableElement) element).getParameters();
+            if (StringUtils.startsWithAny(name, GETTER_SETTER_PREFIXES) && parameters.size() == 1) {
+                findAllAnnotationsOnMember(parameters.get(0), annotationTypes, annotations);
+            }
+        }
+    }
+
+    private TypeMirror getMemberType(final Element element) {
+        switch (element.getKind()) {
+            case FIELD:
+            case PARAMETER:
+                return element.asType();
+            case METHOD:
+                final ExecutableElement executable = (ExecutableElement) element;
+                final TypeMirror returnType = executable.getReturnType();
+                final List<? extends VariableElement> parameters = executable.getParameters();
+                if (returnType.getKind() == TypeKind.VOID && parameters.size() == 1) {
+                    return parameters.get(0).asType();
+                }
+                if (parameters.isEmpty()) {
+                    return returnType;
+                }
+                // fall through
+            default:
+                throw new IllegalArgumentException();
+        }
+    }
+
+    private String getMemberName(final Element element) {
+        String name = element.getSimpleName().toString();
+        if (element instanceof ExecutableElement && StringUtils.startsWithAny(name, GETTER_SETTER_PREFIXES)) {
+            name = name.substring(name.startsWith("is") ? 2 : 3);
+            if (!name.isEmpty()) {
+                final char first = name.charAt(0);
+                name = Character.toLowerCase(first) + name.substring(1);
+            }
+        }
+        return name;
+    }
+
+    private static class ClassNameVisitor extends SimpleTypeVisitor8<String, Void> {
+
+        @Override
+        public String visitDeclared(final DeclaredType t, final Void unused) {
+            final QualifiedNameable typeElement = (QualifiedNameable) t.asElement();
+            return typeElement.getQualifiedName().toString();
+        }
+
+        @Override
+        public String visitPrimitive(final PrimitiveType t, final Void unused) {
+            return switch (t.getKind()) {
+                case BOOLEAN -> "boolean";
+                case BYTE -> "byte";
+                case SHORT -> "short";
+                case INT -> "int";
+                case LONG -> "long";
+                case CHAR -> "char";
+                case FLOAT -> "float";
+                case DOUBLE -> "double";
+                default -> throw new IllegalArgumentException();
+            };
+        }
+    }
+}
diff --git a/log4j-docgen/src/test/it/example/AbstractAppender.java b/log4j-docgen/src/test/it/example/AbstractAppender.java
new file mode 100644
index 0000000..61f1122
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/AbstractAppender.java
@@ -0,0 +1,23 @@
+/*
+ * 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 example;
+
+/**
+ * An example of base abstract class.
+ */
+public abstract class AbstractAppender implements BaseAppender {
+}
diff --git a/log4j-docgen/src/test/it/example/Appender.java b/log4j-docgen/src/test/it/example/Appender.java
new file mode 100644
index 0000000..a2045ae
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/Appender.java
@@ -0,0 +1,23 @@
+/*
+ * 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 example;
+
+/**
+ * Extended interface that also allows to do {@code baz}.
+ */
+public interface Appender extends BaseAppender {
+}
diff --git a/log4j-docgen/src/test/it/example/BaseAppender.java b/log4j-docgen/src/test/it/example/BaseAppender.java
new file mode 100644
index 0000000..e405fec
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/BaseAppender.java
@@ -0,0 +1,23 @@
+/*
+ * 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 example;
+
+/**
+ * Base interface for appenders.
+ */
+public interface BaseAppender {
+}
diff --git a/log4j-docgen/src/test/it/example/Filter.java b/log4j-docgen/src/test/it/example/Filter.java
new file mode 100644
index 0000000..6250ce2
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/Filter.java
@@ -0,0 +1,23 @@
+/*
+ * 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 example;
+
+/**
+ * Filters messages.
+ */
+public interface Filter {
+}
diff --git a/log4j-docgen/src/test/it/example/Layout.java b/log4j-docgen/src/test/it/example/Layout.java
new file mode 100644
index 0000000..5884c6d
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/Layout.java
@@ -0,0 +1,23 @@
+/*
+ * 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 example;
+
+/**
+ * Formats messages.
+ */
+public interface Layout {
+}
diff --git a/log4j-docgen/src/test/it/example/MyAppender.java b/log4j-docgen/src/test/it/example/MyAppender.java
new file mode 100644
index 0000000..45bcbb2
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/MyAppender.java
@@ -0,0 +1,141 @@
+/*
+ * 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 example;
+
+import org.apache.logging.log4j.plugins.Namespace;
+import org.apache.logging.log4j.plugins.Plugin;
+import org.apache.logging.log4j.plugins.PluginBuilderAttribute;
+import org.apache.logging.log4j.plugins.PluginElement;
+import org.apache.logging.log4j.plugins.PluginFactory;
+
+/**
+ * Example plugin
+ * <p>
+ *     This is an example plugin. It has the following characteristics:
+ * </p>
+ * <ol>
+ *     <li>Plugin name: {@code MyPlugin},</li>
+ *     <li>Namespace: default (i.e. {@code Core}).</li>
+ * </ol>
+ * <p>
+ *     It also implements:
+ * </p>
+ * <ul>
+ *     <li>{@link Appender},</li>
+ *     <li>{@link BaseAppender}</li>
+ * </ul>
+ */
+@Plugin
+@Namespace("namespace")
+public final class MyAppender extends AbstractAppender implements Appender {
+
+    public static final class Builder implements org.apache.logging.log4j.plugins.util.Builder<MyAppender> {
+
+        /**
+         * A {@code char} attribute.
+         */
+        @PluginBuilderAttribute
+        private char charAtt = 'L';
+
+        /**
+         * A {@code short} attribute annotated on type.
+         */
+        private @PluginBuilderAttribute short shortAtt = 42;
+
+        /**
+         * An {@code int} attribute.
+         */
+        @PluginBuilderAttribute
+        private int intAtt = 4242;
+
+        /**
+         * A {@code long} attribute annotated on type.
+         */
+        private @PluginBuilderAttribute long longAtt = 424242L;
+
+        /**
+         * A {@code String} attribute.
+         */
+        @PluginBuilderAttribute
+        private String stringAtt;
+
+        /**
+         * An attribute whose name differs from the field name.
+         */
+        @PluginBuilderAttribute("anotherName")
+        private String origName;
+
+        /**
+         * An attribute that is an enumeration annotated on type.
+         */
+        private @PluginBuilderAttribute MyEnum enumAtt;
+
+        /**
+         * An attribute of type {@code float}.
+         */
+        private @PluginBuilderAttribute float floatAtt;
+
+        /**
+         * An attribute of type {@code double}.
+         */
+        private @PluginBuilderAttribute double aDouble;
+
+        /**
+         * An element with multiplicity 1.
+         */
+        @PluginElement
+        private Layout layout;
+
+        private Object notAnAttribute;
+
+        /**
+         * A {@code boolean} attribute with annotated type.
+         */
+        public void setBooleanAtt(final @PluginBuilderAttribute boolean booleanAtt) {}
+
+        /**
+         * A {@code byte} attribute with annotated parameter.
+         */
+        public void setByteAtt(@PluginBuilderAttribute final byte byteAtt) {}
+
+        /**
+         * An element with multiplicity n with annotated setter.
+         */
+        @PluginElement
+        public void setFilters(final Filter[] filters) {}
+
+        /**
+         * An element that is not an interface with annotated parameter.
+         */
+        public void setAbstractElement(@PluginElement final AbstractAppender abstractAppender) {}
+
+        /**
+         * An element with an annotated type.
+         */
+        public void setNestedAppender(final @PluginElement Appender nestedAppender) {}
+
+        @Override
+        public MyAppender build() {
+            return null;
+        }
+    }
+
+    @PluginFactory
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+}
diff --git a/log4j-docgen/src/test/it/example/MyEnum.java b/log4j-docgen/src/test/it/example/MyEnum.java
new file mode 100644
index 0000000..4d7afa8
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/MyEnum.java
@@ -0,0 +1,39 @@
+/*
+ * 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 example;
+
+/**
+ * A very important enum.
+ */
+public enum MyEnum {
+    /**
+     * Makes things go boom!
+     */
+    A,
+    /**
+     * A second choice.
+     */
+    B,
+    /**
+     * Value C.
+     */
+    C,
+    /**
+     * Value D.
+     */
+    D;
+}
diff --git a/log4j-docgen/src/test/it/example/MyOldLayout.java b/log4j-docgen/src/test/it/example/MyOldLayout.java
new file mode 100644
index 0000000..9a6b7c3
--- /dev/null
+++ b/log4j-docgen/src/test/it/example/MyOldLayout.java
@@ -0,0 +1,62 @@
+/*
+ * 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 example;
+
+import org.apache.logging.log4j.plugins.Factory;
+import org.apache.logging.log4j.plugins.Plugin;
+import org.apache.logging.log4j.plugins.PluginAttribute;
+import org.apache.logging.log4j.plugins.PluginElement;
+
+/**
+ * Example plugin without a builder.
+ */
+@Plugin
+public final class MyOldLayout implements Layout {
+
+    /**
+     * @param boolAttr A {@code boolean} attribute.
+     * @param byteAttr A {@code byte} attribute.
+     * @param charAttr A {@code char} attribute.
+     * @param doubleAttr A {@code double} attribute.
+     * @param floatAttr A {@code float} attribute.
+     * @param intAttr An {@code int} attribute.
+     * @param longAttr A {@code long} attribute.
+     * @param shortAttr A {@code short} attribute.
+     * @param stringAttr A {@link String} attribute.
+     * @param origName An attribute with overwritten name.
+     * @param enumAttr An {@code enum} attribute.
+     * @param nestedLayout An element with multiplicity {@code 1}.
+     * @param filters An element with multiplicity {@code n}.
+     */
+    @Factory
+    public static MyOldLayout newLayout(
+            final @PluginAttribute(defaultBoolean = false) boolean boolAttr,
+            final @PluginAttribute(defaultByte = 'L') byte byteAttr,
+            final @PluginAttribute(defaultChar = 'L') char charAttr,
+            final @PluginAttribute(defaultDouble = 42.0) double doubleAttr,
+            final @PluginAttribute(defaultFloat = 42.0f) float floatAttr,
+            final @PluginAttribute(defaultInt = 424242) int intAttr,
+            final @PluginAttribute(defaultLong = 42424242L) long longAttr,
+            final @PluginAttribute(defaultShort = 4242) short shortAttr,
+            final @PluginAttribute String stringAttr,
+            final @PluginAttribute("otherName") String origName,
+            final @PluginAttribute MyEnum enumAttr,
+            final @PluginElement Layout nestedLayout,
+            final @PluginElement Filter[] filters) {
+        return null;
+    }
+}
diff --git a/log4j-docgen/src/test/java/org/apache/logging/log4j/docgen/processor/DocGenProcessorTest.java b/log4j-docgen/src/test/java/org/apache/logging/log4j/docgen/processor/DocGenProcessorTest.java
new file mode 100644
index 0000000..61f0ae2
--- /dev/null
+++ b/log4j-docgen/src/test/java/org/apache/logging/log4j/docgen/processor/DocGenProcessorTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.logging.log4j.docgen.processor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Stream;
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaCompiler.CompilationTask;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+import org.apache.logging.log4j.docgen.xsd.SchemaGenerator;
+import org.junit.jupiter.api.Test;
+import org.xmlunit.assertj3.XmlAssert;
+
+public class DocGenProcessorTest {
+
+    @Test
+    void descriptorGenerationSucceeds() {
+        final Path basePath = Paths.get(System.getProperty("basedir", "."));
+        final Path schema = basePath.resolve("target/generated-site/resources/xsd/plugins-0.1.0.xsd");
+        final URL expected = SchemaGenerator.class.getResource("/expected/processor/META-INF/log4j/plugins.xml");
+        final Path actual = assertDoesNotThrow(DocGenProcessorTest::generateDescriptor);
+        XmlAssert.assertThat(actual)
+                .isValidAgainst(schema)
+                .and(expected)
+                .ignoreComments()
+                .ignoreWhitespace()
+                .areIdentical();
+    }
+
+    private static Path generateDescriptor() throws IOException {
+        final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        final DiagnosticCollector<JavaFileObject> ds = new DiagnosticCollector<>();
+        final StandardJavaFileManager fileManager =
+                compiler.getStandardFileManager(null, Locale.ROOT, StandardCharsets.UTF_8);
+
+        final Path basePath = Paths.get(System.getProperty("basedir", "."));
+        final Path sourcePath = basePath.resolve("src/test/it");
+        final Iterable<? extends JavaFileObject> sources;
+        try (final Stream<Path> files = Files.walk(sourcePath)) {
+            sources = fileManager.getJavaFileObjectsFromPaths(
+                    files.filter(Files::isRegularFile).toList());
+        }
+
+        final Path destPath = basePath.resolve("target/test-site/processor");
+        Files.createDirectories(destPath);
+        fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, Set.of(destPath));
+
+        final CompilationTask task = compiler.getTask(
+                null,
+                fileManager,
+                ds,
+                Arrays.asList("-proc:only", "-processor", DocGenProcessor.class.getName()),
+                null,
+                sources);
+        task.call();
+
+        final List<String> warnings = ds.getDiagnostics().stream()
+                .filter(d -> d.getKind() != Diagnostic.Kind.NOTE)
+                .map(d -> d.getMessage(Locale.ROOT))
+                .toList();
+        assertThat(warnings).isEmpty();
+
+        final Path descriptor = destPath.resolve("META-INF/log4j/plugins.xml");
+        assertThat(descriptor).isNotEmptyFile();
+        return descriptor;
+    }
+}
diff --git a/log4j-docgen/src/test/resources/expected/processor/META-INF/log4j/plugins.xml b/log4j-docgen/src/test/resources/expected/processor/META-INF/log4j/plugins.xml
new file mode 100644
index 0000000..7b4578f
--- /dev/null
+++ b/log4j-docgen/src/test/resources/expected/processor/META-INF/log4j/plugins.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<pluginSet xmlns="https://logging.apache.org/xml/ns/plugins" version="0.1.0">
+    <scalars>
+        <scalar className="example.MyEnum">
+            <values>
+                <value name="A">
+                    <description>Makes things go boom!</description>
+                </value>
+                <value name="B">
+                    <description>A second choice.</description>
+                </value>
+                <value name="C">
+                    <description>Value C.</description>
+                </value>
+                <value name="D">
+                    <description>Value D.</description>
+                </value>
+            </values>
+            <description>A very important enum.</description>
+        </scalar>
+    </scalars>
+    <plugins>
+        <plugin name="MyAppender" namespace="namespace" className="example.MyAppender">
+            <supertypes>
+                <supertype>example.AbstractAppender</supertype>
+                <supertype>example.Appender</supertype>
+                <supertype>example.BaseAppender</supertype>
+                <supertype>java.lang.Object</supertype>
+            </supertypes>
+            <attributes>
+                <attribute name="aDouble" type="double">
+                    <description>An attribute of type `double`.</description>
+                </attribute>
+                <attribute name="anotherName">
+                    <description>An attribute whose name differs from the field name.</description>
+                </attribute>
+                <attribute name="booleanAtt" type="boolean">
+                    <description>A `boolean` attribute with annotated type.</description>
+                </attribute>
+                <attribute name="byteAtt" type="byte">
+                    <description>A `byte` attribute with annotated parameter.</description>
+                </attribute>
+                <attribute name="charAtt" type="char">
+                    <description>A `char` attribute.</description>
+                </attribute>
+                <attribute name="enumAtt" type="example.MyEnum">
+                    <description>An attribute that is an enumeration annotated on type.</description>
+                </attribute>
+                <attribute name="floatAtt" type="float">
+                    <description>An attribute of type `float`.</description>
+                </attribute>
+                <attribute name="intAtt" type="int">
+                    <description>An `int` attribute.</description>
+                </attribute>
+                <attribute name="longAtt" type="long">
+                    <description>A `long` attribute annotated on type.</description>
+                </attribute>
+                <attribute name="shortAtt" type="short">
+                    <description>A `short` attribute annotated on type.</description>
+                </attribute>
+                <attribute name="stringAtt">
+                    <description>A `String` attribute.</description>
+                </attribute>
+            </attributes>
+            <elements>
+                <element type="example.AbstractAppender">
+                    <description>An element that is not an interface with annotated parameter.</description>
+                </element>
+                <element type="example.Appender">
+                    <description>An element with an annotated type.</description>
+                </element>
+                <element multiplicity="*" type="example.Filter">
+                    <description>An element with multiplicity n with annotated setter.</description>
+                </element>
+                <element type="example.Layout">
+                    <description>An element with multiplicity 1.</description>
+                </element>
+            </elements>
+            <description>Example plugin
+
+This is an example plugin.
+It has the following characteristics:
+
+. Plugin name: `MyPlugin`,
+. Namespace: default (i.e. `Core`).
+
+It also implements:
+
+* xref:Appender.adoc[],
+* xref:BaseAppender.adoc[]</description>
+        </plugin>
+        <plugin name="MyOldLayout" className="example.MyOldLayout">
+            <supertypes>
+                <supertype>example.Layout</supertype>
+                <supertype>java.lang.Object</supertype>
+            </supertypes>
+            <attributes>
+                <attribute name="boolAttr" type="boolean">
+                    <description>A `boolean` attribute.</description>
+                </attribute>
+                <attribute name="byteAttr" type="byte">
+                    <description>A `byte` attribute.</description>
+                </attribute>
+                <attribute name="charAttr" type="char">
+                    <description>A `char` attribute.</description>
+                </attribute>
+                <attribute name="doubleAttr" type="double">
+                    <description>A `double` attribute.</description>
+                </attribute>
+                <attribute name="enumAttr" type="example.MyEnum">
+                    <description>An `enum` attribute.</description>
+                </attribute>
+                <attribute name="floatAttr" type="float">
+                    <description>A `float` attribute.</description>
+                </attribute>
+                <attribute name="intAttr" type="int">
+                    <description>An `int` attribute.</description>
+                </attribute>
+                <attribute name="longAttr" type="long">
+                    <description>A `long` attribute.</description>
+                </attribute>
+                <attribute name="otherName">
+                    <description>An attribute with overwritten name.</description>
+                </attribute>
+                <attribute name="shortAttr" type="short">
+                    <description>A `short` attribute.</description>
+                </attribute>
+                <attribute name="stringAttr">
+                    <description>A xref:String.adoc[] attribute.</description>
+                </attribute>
+            </attributes>
+            <elements>
+                <element multiplicity="*" type="example.Filter">
+                    <description>An element with multiplicity `n`.</description>
+                </element>
+                <element type="example.Layout">
+                    <description>An element with multiplicity `1`.</description>
+                </element>
+            </elements>
+            <description>Example plugin without a builder.</description>
+        </plugin>
+    </plugins>
+    <abstractTypes>
+        <abstractType className="example.AbstractAppender">
+            <description>An example of base abstract class.</description>
+        </abstractType>
+        <abstractType className="example.Appender">
+            <description>Extended interface that also allows to do `baz`.</description>
+        </abstractType>
+        <abstractType className="example.BaseAppender">
+            <description>Base interface for appenders.</description>
+        </abstractType>
+        <abstractType className="example.Layout">
+            <description>Formats messages.</description>
+        </abstractType>
+    </abstractTypes>
+</pluginSet>