gnodet-bot commented on code in PR #13658: URL: https://github.com/apache/maven/pull/13658#discussion_r4330288179
########## api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java: ########## @@ -0,0 +1,876 @@ +/* + * 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.maven.plugin.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedOptions; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml}) + * and DI factory classes at compile time from {@code @Mojo}-annotated classes. + * + * <p>This processor requires the following compiler options (via {@code -A}): + * <ul> + * <li>{@code maven.plugin.groupId} — the plugin's group ID</li> + * <li>{@code maven.plugin.artifactId} — the plugin's artifact ID</li> + * <li>{@code maven.plugin.version} — the plugin's version</li> + * <li>{@code maven.plugin.goalPrefix} — optional; derived from artifactId if absent</li> + * </ul> + * + * <p>The generated {@code plugin.xml} follows the {@code http://maven.apache.org/PLUGIN/2.0.0} namespace. + * One factory class is generated per mojo; the factory is annotated with + * {@code @org.apache.maven.api.di.Named("groupId:artifactId:version:goal")} so that the DI container + * can locate it by the role-hint used in {@code DefaultMavenPluginManager.loadV4Mojo()}. + * The {@code DiIndexProcessor} (from {@code maven-api-di}) picks up the {@code @Named} factory in the + * next processing round and registers it in {@code META-INF/maven/org.apache.maven.api.di.Inject}. + * + * <p>Design notes: + * <ul> + * <li>Returns {@code false} from {@code process()} — does not claim {@code @Mojo}; other processors + * and IDEs can still see it.</li> + * <li>Annotation references are string-based — no compile dependency on {@code maven-api-core} or + * {@code maven-api-di}; mirror names are compared as strings.</li> + * <li>Full type hierarchy is walked to collect {@code @Parameter} and {@code @Resolution} fields, + * including those declared in parent classes from dependency JARs.</li> + * <li>Javadoc is extracted via {@code Elements.getDocComment()} — only available for source elements; + * inherited parameters from binary JARs will have {@code null} descriptions.</li> + * </ul> + * + * @since 4.1.0 + */ +@SupportedAnnotationTypes("org.apache.maven.api.plugin.annotations.Mojo") +@SupportedOptions({ + MojoDescriptorProcessor.OPT_GROUP_ID, + MojoDescriptorProcessor.OPT_ARTIFACT_ID, + MojoDescriptorProcessor.OPT_VERSION, + MojoDescriptorProcessor.OPT_GOAL_PREFIX +}) +public class MojoDescriptorProcessor extends AbstractProcessor { + + static final String OPT_GROUP_ID = "maven.plugin.groupId"; + static final String OPT_ARTIFACT_ID = "maven.plugin.artifactId"; + static final String OPT_VERSION = "maven.plugin.version"; + static final String OPT_GOAL_PREFIX = "maven.plugin.goalPrefix"; + + private static final String MOJO_ANN = "org.apache.maven.api.plugin.annotations.Mojo"; + private static final String PARAMETER_ANN = "org.apache.maven.api.plugin.annotations.Parameter"; + private static final String RESOLUTION_ANN = "org.apache.maven.api.plugin.annotations.Resolution"; + private static final String EXECUTE_ANN = "org.apache.maven.api.plugin.annotations.Execute"; + private static final String AFTER_ANN = "org.apache.maven.api.plugin.annotations.After"; + private static final String AFTERS_ANN = "org.apache.maven.api.plugin.annotations.Afters"; + + private static final String PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0"; + + /** Mojos collected across all rounds (FQN → TypeElement). */ + private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>(); + + /** Factory class names generated in previous rounds — skip re-processing them. */ + private final Set<String> generatedFactoryNames = new LinkedHashSet<>(); + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { + for (TypeElement annotation : annotations) { + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (!(element instanceof TypeElement typeElement)) { + continue; + } + String fqn = typeElement.getQualifiedName().toString(); + // Skip factory classes we generated in a previous round + if (generatedFactoryNames.contains(fqn)) { + continue; + } + // Validate: must be a concrete class + if (typeElement.getKind() == ElementKind.INTERFACE) { + processingEnv + .getMessager() + .printMessage(Diagnostic.Kind.ERROR, "@Mojo must not be placed on an interface", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "@Mojo must not be placed on an abstract class", element); + continue; + } + if (!mojoElements.containsKey(fqn)) { + mojoElements.put(fqn, typeElement); + // Generate factory immediately in this round so DiIndexProcessor can pick it + // up in the next round. Factories generated in processingOver() are invisible + // to subsequent processors because there are no more rounds after the last one. + try { + generateFactory(typeElement); + } catch (IOException e) { + logError("Failed to generate factory for " + fqn, e); + } + } + } + } + + if (roundEnv.processingOver()) { + if (mojoElements.isEmpty()) { + return false; + } + if (!validateOptions()) { + return false; + } + try { + writePluginXml(); + } catch (Exception e) { Review Comment: 🐛 **Broad `catch (Exception e)` for `writePluginXml()`.** `writePluginXml()` declares `throws IOException, XMLStreamException` — those are the only checked exceptions that can escape. Catching `Exception` also silently swallows `NullPointerException`, `ClassCastException`, and other programming errors, reporting them as a generic "Failed to generate plugin.xml" message that makes processor bugs impossible to diagnose. Previously raised — still not fixed. ```suggestion } catch (IOException | XMLStreamException e) { ``` ########## api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java: ########## @@ -0,0 +1,876 @@ +/* + * 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.maven.plugin.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedOptions; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml}) + * and DI factory classes at compile time from {@code @Mojo}-annotated classes. + * + * <p>This processor requires the following compiler options (via {@code -A}): + * <ul> + * <li>{@code maven.plugin.groupId} — the plugin's group ID</li> + * <li>{@code maven.plugin.artifactId} — the plugin's artifact ID</li> + * <li>{@code maven.plugin.version} — the plugin's version</li> + * <li>{@code maven.plugin.goalPrefix} — optional; derived from artifactId if absent</li> + * </ul> + * + * <p>The generated {@code plugin.xml} follows the {@code http://maven.apache.org/PLUGIN/2.0.0} namespace. + * One factory class is generated per mojo; the factory is annotated with + * {@code @org.apache.maven.api.di.Named("groupId:artifactId:version:goal")} so that the DI container + * can locate it by the role-hint used in {@code DefaultMavenPluginManager.loadV4Mojo()}. + * The {@code DiIndexProcessor} (from {@code maven-api-di}) picks up the {@code @Named} factory in the + * next processing round and registers it in {@code META-INF/maven/org.apache.maven.api.di.Inject}. + * + * <p>Design notes: + * <ul> + * <li>Returns {@code false} from {@code process()} — does not claim {@code @Mojo}; other processors + * and IDEs can still see it.</li> + * <li>Annotation references are string-based — no compile dependency on {@code maven-api-core} or + * {@code maven-api-di}; mirror names are compared as strings.</li> + * <li>Full type hierarchy is walked to collect {@code @Parameter} and {@code @Resolution} fields, + * including those declared in parent classes from dependency JARs.</li> + * <li>Javadoc is extracted via {@code Elements.getDocComment()} — only available for source elements; + * inherited parameters from binary JARs will have {@code null} descriptions.</li> + * </ul> + * + * @since 4.1.0 + */ +@SupportedAnnotationTypes("org.apache.maven.api.plugin.annotations.Mojo") +@SupportedOptions({ + MojoDescriptorProcessor.OPT_GROUP_ID, + MojoDescriptorProcessor.OPT_ARTIFACT_ID, + MojoDescriptorProcessor.OPT_VERSION, + MojoDescriptorProcessor.OPT_GOAL_PREFIX +}) +public class MojoDescriptorProcessor extends AbstractProcessor { + + static final String OPT_GROUP_ID = "maven.plugin.groupId"; + static final String OPT_ARTIFACT_ID = "maven.plugin.artifactId"; + static final String OPT_VERSION = "maven.plugin.version"; + static final String OPT_GOAL_PREFIX = "maven.plugin.goalPrefix"; + + private static final String MOJO_ANN = "org.apache.maven.api.plugin.annotations.Mojo"; + private static final String PARAMETER_ANN = "org.apache.maven.api.plugin.annotations.Parameter"; + private static final String RESOLUTION_ANN = "org.apache.maven.api.plugin.annotations.Resolution"; + private static final String EXECUTE_ANN = "org.apache.maven.api.plugin.annotations.Execute"; + private static final String AFTER_ANN = "org.apache.maven.api.plugin.annotations.After"; + private static final String AFTERS_ANN = "org.apache.maven.api.plugin.annotations.Afters"; + + private static final String PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0"; + + /** Mojos collected across all rounds (FQN → TypeElement). */ + private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>(); + + /** Factory class names generated in previous rounds — skip re-processing them. */ + private final Set<String> generatedFactoryNames = new LinkedHashSet<>(); + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { + for (TypeElement annotation : annotations) { + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (!(element instanceof TypeElement typeElement)) { + continue; + } + String fqn = typeElement.getQualifiedName().toString(); + // Skip factory classes we generated in a previous round + if (generatedFactoryNames.contains(fqn)) { + continue; + } + // Validate: must be a concrete class + if (typeElement.getKind() == ElementKind.INTERFACE) { + processingEnv + .getMessager() + .printMessage(Diagnostic.Kind.ERROR, "@Mojo must not be placed on an interface", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "@Mojo must not be placed on an abstract class", element); + continue; + } + if (!mojoElements.containsKey(fqn)) { + mojoElements.put(fqn, typeElement); + // Generate factory immediately in this round so DiIndexProcessor can pick it + // up in the next round. Factories generated in processingOver() are invisible + // to subsequent processors because there are no more rounds after the last one. + try { + generateFactory(typeElement); + } catch (IOException e) { + logError("Failed to generate factory for " + fqn, e); + } + } + } + } + + if (roundEnv.processingOver()) { + if (mojoElements.isEmpty()) { + return false; + } + if (!validateOptions()) { + return false; + } + try { + writePluginXml(); + } catch (Exception e) { + logError("Failed to generate plugin.xml", e); + } + } + + return false; // do not claim the annotation + } + + // ------------------------------------------------------------------------- + // Option validation + // ------------------------------------------------------------------------- + + /** + * Validates that all required compiler options are present. + * + * @return {@code true} if all required options are present, {@code false} otherwise + */ + private boolean validateOptions() { + boolean ok = true; + for (String opt : new String[] {OPT_GROUP_ID, OPT_ARTIFACT_ID, OPT_VERSION}) { + if (getOption(opt) == null) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Required compiler option -A" + opt + " is missing. " + + "Add it to the maven-compiler-plugin configuration."); + ok = false; + } + } + return ok; + } + + private String getOption(String key) { + return processingEnv.getOptions().get(key); + } + + private String getOptionOrDefault(String key, String defaultValue) { + String v = getOption(key); + return (v != null && !v.isEmpty()) ? v : defaultValue; + } + + // ------------------------------------------------------------------------- + // plugin.xml generation + // ------------------------------------------------------------------------- + + private void writePluginXml() throws IOException, XMLStreamException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + String goalPrefix = getOptionOrDefault(OPT_GOAL_PREFIX, deriveGoalPrefix(artifactId)); + + FileObject out = + processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml"); + + try (Writer w = out.openWriter()) { + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + XMLStreamWriter xml = factory.createXMLStreamWriter(w); + try { + xml.writeStartDocument("UTF-8", "1.0"); + xml.writeCharacters("\n"); + xml.writeStartElement("plugin"); + xml.writeDefaultNamespace(PLUGIN_NS); + xml.writeCharacters("\n"); + + writeElement(xml, "name", artifactId, 2); + writeElement(xml, "groupId", groupId, 2); + writeElement(xml, "artifactId", artifactId, 2); + writeElement(xml, "version", version, 2); + writeElement(xml, "goalPrefix", goalPrefix, 2); + + xml.writeCharacters(" "); + xml.writeStartElement("mojos"); + xml.writeCharacters("\n"); + + for (TypeElement mojoType : mojoElements.values()) { + writeMojo(xml, mojoType, groupId, artifactId, version); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojos + xml.writeCharacters("\n"); + xml.writeEndElement(); // plugin + xml.writeCharacters("\n"); + xml.writeEndDocument(); + } finally { + xml.close(); + } + } + note("Generated META-INF/maven/plugin.xml with " + mojoElements.size() + " mojo(s)"); + } + + private void writeMojo(XMLStreamWriter xml, TypeElement mojoType, String groupId, String artifactId, String version) + throws XMLStreamException { + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType); + return; + } + + // Javadoc for the mojo class + JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType)); + + xml.writeCharacters(" "); + xml.writeStartElement("mojo"); + xml.writeCharacters("\n"); + + writeElement(xml, "goal", goal, 6); + writeElementIfNotEmpty(xml, "phase", getStringValue(mojoAnn, "defaultPhase", ""), 6); + writeElement(xml, "implementation", fqn, 6); + writeElement(xml, "language", "java", 6); + writeElementIfNotEmpty(xml, "description", mojoDoc.description, 6); + writeElementIfNotEmpty(xml, "since", mojoDoc.since, 6); + writeElementIfNotEmpty(xml, "deprecated", mojoDoc.deprecated, 6); + writeElement(xml, "projectRequired", String.valueOf(getBooleanValue(mojoAnn, "projectRequired", true)), 6); + writeElement(xml, "onlineRequired", String.valueOf(getBooleanValue(mojoAnn, "onlineRequired", false)), 6); + writeElement(xml, "aggregator", String.valueOf(getBooleanValue(mojoAnn, "aggregator", false)), 6); + writeElementIfNotEmpty(xml, "configurator", getStringValue(mojoAnn, "configurator", ""), 6); + + // @Execute + AnnotationMirror executeAnn = findAnnotation(mojoType, EXECUTE_ANN); + if (executeAnn != null) { + writeElementIfNotEmpty(xml, "executePhase", getStringValue(executeAnn, "phase", ""), 6); + writeElementIfNotEmpty(xml, "executeGoal", getStringValue(executeAnn, "goal", ""), 6); + writeElementIfNotEmpty(xml, "executeLifecycle", getStringValue(executeAnn, "lifecycle", ""), 6); + } + + // dependencyResolution / dependencyCollection from @Mojo + writeElementIfNotEmpty( + xml, "dependencyResolution", getStringValue(mojoAnn, "dependencyResolutionPathScopes", ""), 6); + boolean depCollection = getBooleanValue(mojoAnn, "dependencyCollection", false); + if (depCollection) { + writeElement(xml, "dependencyCollection", "true", 6); + } + + // @Parameter fields — walk the full type hierarchy + List<ParameterInfo> params = collectParameters(mojoType); + if (!params.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("parameters"); + xml.writeCharacters("\n"); + for (ParameterInfo p : params) { + writeParameter(xml, p); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // parameters + xml.writeCharacters("\n"); + } + + // @Resolution fields + List<ResolutionInfo> resolutions = collectResolutions(mojoType); + if (!resolutions.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("resolutions"); + xml.writeCharacters("\n"); + for (ResolutionInfo r : resolutions) { + writeResolution(xml, r); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // resolutions + xml.writeCharacters("\n"); + } + + // @After / @Afters + List<AfterInfo> afters = collectAfters(mojoType); + if (!afters.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("afterLinks"); + xml.writeCharacters("\n"); + for (AfterInfo a : afters) { + writeAfterLink(xml, a); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLinks + xml.writeCharacters("\n"); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojo + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Parameter collection + // ------------------------------------------------------------------------- + + private List<ParameterInfo> collectParameters(TypeElement mojoType) { + List<ParameterInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) { + continue; + } + AnnotationMirror paramAnn = findAnnotation(enclosed, PARAMETER_ANN); + if (paramAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + String paramName = getStringValue(paramAnn, "name", ""); + if (paramName.isEmpty()) { + paramName = fieldName; + } + if (seen.contains(paramName)) { + continue; // subclass overrides parent + } + seen.add(paramName); + + String type; + if (enclosed.getKind() == ElementKind.FIELD) { + type = ((VariableElement) enclosed).asType().toString(); + } else { + // method: use parameter type of setter, or return type of getter + ExecutableElement method = (ExecutableElement) enclosed; + if (!method.getParameters().isEmpty()) { + type = method.getParameters().get(0).asType().toString(); + } else { + type = method.getReturnType().toString(); + } + } + + JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed)); + + ParameterInfo p = new ParameterInfo(); + p.name = paramName; + p.alias = getStringValue(paramAnn, "alias", ""); + p.type = type; + p.required = getBooleanValue(paramAnn, "required", false); + p.editable = !getBooleanValue(paramAnn, "readonly", false); // inverted! + p.expression = getStringValue(paramAnn, "property", ""); + if (!p.expression.isEmpty() && !p.expression.startsWith("${")) { + p.expression = "${" + p.expression + "}"; + } + p.defaultValue = getStringValue(paramAnn, "defaultValue", ""); + p.description = doc.description; + p.since = doc.since; + p.deprecated = doc.deprecated; + result.add(p); + } + current = superTypeElement(current); + } + return result; + } + + private void writeParameter(XMLStreamWriter xml, ParameterInfo p) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("parameter"); + xml.writeCharacters("\n"); + writeElement(xml, "name", p.name, 10); + writeElementIfNotEmpty(xml, "alias", p.alias, 10); + writeElement(xml, "type", p.type, 10); + writeElement(xml, "required", String.valueOf(p.required), 10); + writeElement(xml, "editable", String.valueOf(p.editable), 10); + writeElementIfNotEmpty(xml, "description", p.description, 10); + writeElementIfNotEmpty(xml, "expression", p.expression, 10); + writeElementIfNotEmpty(xml, "defaultValue", p.defaultValue, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // parameter + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Resolution collection + // ------------------------------------------------------------------------- + + private List<ResolutionInfo> collectResolutions(TypeElement mojoType) { + List<ResolutionInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD) { + continue; + } + AnnotationMirror resAnn = findAnnotation(enclosed, RESOLUTION_ANN); + if (resAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + if (seen.contains(fieldName)) { + continue; + } + seen.add(fieldName); + + ResolutionInfo r = new ResolutionInfo(); + r.field = fieldName; + r.pathScope = getStringValue(resAnn, "pathScope", ""); + r.requestType = getStringValue(resAnn, "requestType", ""); + result.add(r); + } + current = superTypeElement(current); + } + return result; + } + + private void writeResolution(XMLStreamWriter xml, ResolutionInfo r) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("resolution"); + xml.writeCharacters("\n"); + writeElement(xml, "field", r.field, 10); + writeElementIfNotEmpty(xml, "pathScope", r.pathScope, 10); + writeElementIfNotEmpty(xml, "requestType", r.requestType, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // resolution + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // @After / @Afters collection + // ------------------------------------------------------------------------- + + private List<AfterInfo> collectAfters(TypeElement mojoType) { + List<AfterInfo> result = new ArrayList<>(); + // Check for @Afters (container) first + AnnotationMirror aftersAnn = findAnnotation(mojoType, AFTERS_ANN); + if (aftersAnn != null) { + AnnotationValue valueAttr = getAnnotationValue(aftersAnn, "value"); + if (valueAttr != null && valueAttr.getValue() instanceof List<?> list) { + for (Object item : list) { + if (item instanceof AnnotationValue av && av.getValue() instanceof AnnotationMirror afterMirror) { + result.add(extractAfter(afterMirror)); + } + } + } + } + // Also check for a single @After directly on the class + AnnotationMirror afterAnn = findAnnotation(mojoType, AFTER_ANN); + if (afterAnn != null && aftersAnn == null) { + result.add(extractAfter(afterAnn)); + } + return result; + } + + private AfterInfo extractAfter(AnnotationMirror ann) { + AfterInfo a = new AfterInfo(); + a.phase = getStringValue(ann, "phase", ""); + a.type = getEnumValue(ann, "type", "PROJECT"); + a.scope = getStringValue(ann, "scope", ""); + return a; + } + + private void writeAfterLink(XMLStreamWriter xml, AfterInfo a) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("afterLink"); + xml.writeCharacters("\n"); + writeElementIfNotEmpty(xml, "phase", a.phase, 10); + writeElement(xml, "type", a.type, 10); + writeElementIfNotEmpty(xml, "scope", a.scope, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLink + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Factory class generation + // ------------------------------------------------------------------------- + + private void generateFactory(TypeElement mojoType) throws IOException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + + // Options may not be set yet at factory-generation time (first round before processingOver) + // — use placeholders that get resolved when plugin.xml is written, but the @Named value + // must be stable. If GAV options are missing we skip factory generation; validateOptions() + // will emit the proper error in processingOver(). + if (groupId == null || artifactId == null || version == null) { + return; + } + + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : ""; + String simpleName = mojoType.getSimpleName().toString(); + String factorySimpleName = simpleName + "$MojoFactory"; + String factoryFqn = pkg.isEmpty() ? factorySimpleName : pkg + "." + factorySimpleName; + String roleHint = groupId + ":" + artifactId + ":" + version + ":" + goal; + + // Collect non-private constructors to replicate in the factory + List<ExecutableElement> ctors = new ArrayList<>(); + for (Element enc : mojoType.getEnclosedElements()) { + if (enc.getKind() == ElementKind.CONSTRUCTOR) { + ExecutableElement ctor = (ExecutableElement) enc; + if (!ctor.getModifiers().contains(Modifier.PRIVATE)) { + ctors.add(ctor); + } + } + } + + JavaFileObject src = processingEnv.getFiler().createSourceFile(factoryFqn, mojoType); + try (PrintWriter pw = new PrintWriter(src.openWriter())) { + writeLicenseHeader(pw); + if (!pkg.isEmpty()) { + pw.println("package " + pkg + ";"); + pw.println(); + } + pw.println("/**"); + pw.println(" * DI factory for the {@code " + goal + "} mojo."); + pw.println(" * Generated by {@link org.apache.maven.plugin.processor.MojoDescriptorProcessor}."); + pw.println(" *"); + pw.println(" * @since 4.1.0"); + pw.println(" */"); + pw.println("@org.apache.maven.api.di.Named(\"" + roleHint + "\")"); + pw.println("public class " + factorySimpleName + " extends " + simpleName + " {"); + pw.println(); + + if (!ctors.isEmpty() && !hasOnlyDefaultCtor(ctors)) { + for (ExecutableElement ctor : ctors) { + if (ctor.getParameters().isEmpty()) { + continue; // default ctor — implicit delegation to super + } + pw.print(" public " + factorySimpleName + "("); + List<? extends VariableElement> params = ctor.getParameters(); + StringBuilder args = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + VariableElement param = params.get(i); + String typeName = param.asType().toString(); + String paramName = param.getSimpleName().toString(); + if (i > 0) { + pw.print(", "); + args.append(", "); + } + pw.print(typeName + " " + paramName); + args.append(paramName); + } + pw.println(") {"); + pw.println(" super(" + args + ");"); + pw.println(" }"); + pw.println(); + } + } + + pw.println("}"); + } + + generatedFactoryNames.add(factoryFqn); + note("Generated factory " + factoryFqn + " for goal '" + goal + "'"); + } + + private boolean hasOnlyDefaultCtor(List<ExecutableElement> ctors) { + return ctors.size() == 1 && ctors.get(0).getParameters().isEmpty(); + } + + // ------------------------------------------------------------------------- + // Javadoc parsing + // ------------------------------------------------------------------------- + + private static JavadocInfo parseJavadoc(String raw) { + JavadocInfo info = new JavadocInfo(); + if (raw == null || raw.isBlank()) { + return info; + } + // Split on block Javadoc tags (tags at start of a line, possibly with leading whitespace) + String[] parts = raw.split("(?m)^\\s*@"); Review Comment: ⚠️ **Greedy Javadoc `@` regex still present.** `(?m)^\s*@` splits on **any** `@` at the start of a line, including email addresses in Javadoc (`@example.com`), `{@link ...}` / `{@code ...}` blocks that wrap to a new line, and annotation names in embedded code examples. This silently truncates mojo descriptions. Previously raised — still not fixed. ```suggestion String[] parts = raw.split("(?m)^\\s*@(?=param|return|throws|since|deprecated|see|author|version)\\b"); ``` ########## api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java: ########## @@ -0,0 +1,876 @@ +/* + * 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.maven.plugin.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedOptions; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml}) + * and DI factory classes at compile time from {@code @Mojo}-annotated classes. + * + * <p>This processor requires the following compiler options (via {@code -A}): + * <ul> + * <li>{@code maven.plugin.groupId} — the plugin's group ID</li> + * <li>{@code maven.plugin.artifactId} — the plugin's artifact ID</li> + * <li>{@code maven.plugin.version} — the plugin's version</li> + * <li>{@code maven.plugin.goalPrefix} — optional; derived from artifactId if absent</li> + * </ul> + * + * <p>The generated {@code plugin.xml} follows the {@code http://maven.apache.org/PLUGIN/2.0.0} namespace. + * One factory class is generated per mojo; the factory is annotated with + * {@code @org.apache.maven.api.di.Named("groupId:artifactId:version:goal")} so that the DI container + * can locate it by the role-hint used in {@code DefaultMavenPluginManager.loadV4Mojo()}. + * The {@code DiIndexProcessor} (from {@code maven-api-di}) picks up the {@code @Named} factory in the + * next processing round and registers it in {@code META-INF/maven/org.apache.maven.api.di.Inject}. + * + * <p>Design notes: + * <ul> + * <li>Returns {@code false} from {@code process()} — does not claim {@code @Mojo}; other processors + * and IDEs can still see it.</li> + * <li>Annotation references are string-based — no compile dependency on {@code maven-api-core} or + * {@code maven-api-di}; mirror names are compared as strings.</li> + * <li>Full type hierarchy is walked to collect {@code @Parameter} and {@code @Resolution} fields, + * including those declared in parent classes from dependency JARs.</li> + * <li>Javadoc is extracted via {@code Elements.getDocComment()} — only available for source elements; + * inherited parameters from binary JARs will have {@code null} descriptions.</li> + * </ul> + * + * @since 4.1.0 + */ +@SupportedAnnotationTypes("org.apache.maven.api.plugin.annotations.Mojo") +@SupportedOptions({ + MojoDescriptorProcessor.OPT_GROUP_ID, + MojoDescriptorProcessor.OPT_ARTIFACT_ID, + MojoDescriptorProcessor.OPT_VERSION, + MojoDescriptorProcessor.OPT_GOAL_PREFIX +}) +public class MojoDescriptorProcessor extends AbstractProcessor { + + static final String OPT_GROUP_ID = "maven.plugin.groupId"; + static final String OPT_ARTIFACT_ID = "maven.plugin.artifactId"; + static final String OPT_VERSION = "maven.plugin.version"; + static final String OPT_GOAL_PREFIX = "maven.plugin.goalPrefix"; + + private static final String MOJO_ANN = "org.apache.maven.api.plugin.annotations.Mojo"; + private static final String PARAMETER_ANN = "org.apache.maven.api.plugin.annotations.Parameter"; + private static final String RESOLUTION_ANN = "org.apache.maven.api.plugin.annotations.Resolution"; + private static final String EXECUTE_ANN = "org.apache.maven.api.plugin.annotations.Execute"; + private static final String AFTER_ANN = "org.apache.maven.api.plugin.annotations.After"; + private static final String AFTERS_ANN = "org.apache.maven.api.plugin.annotations.Afters"; + + private static final String PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0"; + + /** Mojos collected across all rounds (FQN → TypeElement). */ + private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>(); + + /** Factory class names generated in previous rounds — skip re-processing them. */ + private final Set<String> generatedFactoryNames = new LinkedHashSet<>(); + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { + for (TypeElement annotation : annotations) { + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (!(element instanceof TypeElement typeElement)) { + continue; + } + String fqn = typeElement.getQualifiedName().toString(); + // Skip factory classes we generated in a previous round + if (generatedFactoryNames.contains(fqn)) { + continue; + } + // Validate: must be a concrete class + if (typeElement.getKind() == ElementKind.INTERFACE) { + processingEnv + .getMessager() + .printMessage(Diagnostic.Kind.ERROR, "@Mojo must not be placed on an interface", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "@Mojo must not be placed on an abstract class", element); + continue; + } + if (!mojoElements.containsKey(fqn)) { + mojoElements.put(fqn, typeElement); + // Generate factory immediately in this round so DiIndexProcessor can pick it + // up in the next round. Factories generated in processingOver() are invisible + // to subsequent processors because there are no more rounds after the last one. + try { + generateFactory(typeElement); + } catch (IOException e) { + logError("Failed to generate factory for " + fqn, e); + } + } + } + } + + if (roundEnv.processingOver()) { + if (mojoElements.isEmpty()) { + return false; + } + if (!validateOptions()) { + return false; + } + try { + writePluginXml(); + } catch (Exception e) { + logError("Failed to generate plugin.xml", e); + } + } + + return false; // do not claim the annotation + } + + // ------------------------------------------------------------------------- + // Option validation + // ------------------------------------------------------------------------- + + /** + * Validates that all required compiler options are present. + * + * @return {@code true} if all required options are present, {@code false} otherwise + */ + private boolean validateOptions() { + boolean ok = true; + for (String opt : new String[] {OPT_GROUP_ID, OPT_ARTIFACT_ID, OPT_VERSION}) { + if (getOption(opt) == null) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Required compiler option -A" + opt + " is missing. " + + "Add it to the maven-compiler-plugin configuration."); + ok = false; + } + } + return ok; + } + + private String getOption(String key) { + return processingEnv.getOptions().get(key); + } + + private String getOptionOrDefault(String key, String defaultValue) { + String v = getOption(key); + return (v != null && !v.isEmpty()) ? v : defaultValue; + } + + // ------------------------------------------------------------------------- + // plugin.xml generation + // ------------------------------------------------------------------------- + + private void writePluginXml() throws IOException, XMLStreamException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + String goalPrefix = getOptionOrDefault(OPT_GOAL_PREFIX, deriveGoalPrefix(artifactId)); + + FileObject out = + processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml"); + + try (Writer w = out.openWriter()) { + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + XMLStreamWriter xml = factory.createXMLStreamWriter(w); + try { + xml.writeStartDocument("UTF-8", "1.0"); + xml.writeCharacters("\n"); + xml.writeStartElement("plugin"); + xml.writeDefaultNamespace(PLUGIN_NS); + xml.writeCharacters("\n"); + + writeElement(xml, "name", artifactId, 2); + writeElement(xml, "groupId", groupId, 2); + writeElement(xml, "artifactId", artifactId, 2); + writeElement(xml, "version", version, 2); + writeElement(xml, "goalPrefix", goalPrefix, 2); + + xml.writeCharacters(" "); + xml.writeStartElement("mojos"); + xml.writeCharacters("\n"); + + for (TypeElement mojoType : mojoElements.values()) { + writeMojo(xml, mojoType, groupId, artifactId, version); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojos + xml.writeCharacters("\n"); + xml.writeEndElement(); // plugin + xml.writeCharacters("\n"); + xml.writeEndDocument(); + } finally { + xml.close(); + } + } + note("Generated META-INF/maven/plugin.xml with " + mojoElements.size() + " mojo(s)"); + } + + private void writeMojo(XMLStreamWriter xml, TypeElement mojoType, String groupId, String artifactId, String version) + throws XMLStreamException { + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType); + return; + } + + // Javadoc for the mojo class + JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType)); + + xml.writeCharacters(" "); + xml.writeStartElement("mojo"); + xml.writeCharacters("\n"); + + writeElement(xml, "goal", goal, 6); + writeElementIfNotEmpty(xml, "phase", getStringValue(mojoAnn, "defaultPhase", ""), 6); + writeElement(xml, "implementation", fqn, 6); + writeElement(xml, "language", "java", 6); + writeElementIfNotEmpty(xml, "description", mojoDoc.description, 6); + writeElementIfNotEmpty(xml, "since", mojoDoc.since, 6); + writeElementIfNotEmpty(xml, "deprecated", mojoDoc.deprecated, 6); + writeElement(xml, "projectRequired", String.valueOf(getBooleanValue(mojoAnn, "projectRequired", true)), 6); + writeElement(xml, "onlineRequired", String.valueOf(getBooleanValue(mojoAnn, "onlineRequired", false)), 6); + writeElement(xml, "aggregator", String.valueOf(getBooleanValue(mojoAnn, "aggregator", false)), 6); + writeElementIfNotEmpty(xml, "configurator", getStringValue(mojoAnn, "configurator", ""), 6); + + // @Execute + AnnotationMirror executeAnn = findAnnotation(mojoType, EXECUTE_ANN); + if (executeAnn != null) { + writeElementIfNotEmpty(xml, "executePhase", getStringValue(executeAnn, "phase", ""), 6); + writeElementIfNotEmpty(xml, "executeGoal", getStringValue(executeAnn, "goal", ""), 6); + writeElementIfNotEmpty(xml, "executeLifecycle", getStringValue(executeAnn, "lifecycle", ""), 6); + } + + // dependencyResolution / dependencyCollection from @Mojo + writeElementIfNotEmpty( + xml, "dependencyResolution", getStringValue(mojoAnn, "dependencyResolutionPathScopes", ""), 6); + boolean depCollection = getBooleanValue(mojoAnn, "dependencyCollection", false); + if (depCollection) { + writeElement(xml, "dependencyCollection", "true", 6); + } + + // @Parameter fields — walk the full type hierarchy + List<ParameterInfo> params = collectParameters(mojoType); + if (!params.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("parameters"); + xml.writeCharacters("\n"); + for (ParameterInfo p : params) { + writeParameter(xml, p); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // parameters + xml.writeCharacters("\n"); + } + + // @Resolution fields + List<ResolutionInfo> resolutions = collectResolutions(mojoType); + if (!resolutions.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("resolutions"); + xml.writeCharacters("\n"); + for (ResolutionInfo r : resolutions) { + writeResolution(xml, r); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // resolutions + xml.writeCharacters("\n"); + } + + // @After / @Afters + List<AfterInfo> afters = collectAfters(mojoType); + if (!afters.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("afterLinks"); + xml.writeCharacters("\n"); + for (AfterInfo a : afters) { + writeAfterLink(xml, a); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLinks + xml.writeCharacters("\n"); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojo + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Parameter collection + // ------------------------------------------------------------------------- + + private List<ParameterInfo> collectParameters(TypeElement mojoType) { + List<ParameterInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) { + continue; + } + AnnotationMirror paramAnn = findAnnotation(enclosed, PARAMETER_ANN); + if (paramAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + String paramName = getStringValue(paramAnn, "name", ""); + if (paramName.isEmpty()) { + paramName = fieldName; + } + if (seen.contains(paramName)) { + continue; // subclass overrides parent + } + seen.add(paramName); + + String type; + if (enclosed.getKind() == ElementKind.FIELD) { + type = ((VariableElement) enclosed).asType().toString(); + } else { + // method: use parameter type of setter, or return type of getter + ExecutableElement method = (ExecutableElement) enclosed; + if (!method.getParameters().isEmpty()) { + type = method.getParameters().get(0).asType().toString(); + } else { + type = method.getReturnType().toString(); + } + } + + JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed)); + + ParameterInfo p = new ParameterInfo(); + p.name = paramName; + p.alias = getStringValue(paramAnn, "alias", ""); + p.type = type; + p.required = getBooleanValue(paramAnn, "required", false); + p.editable = !getBooleanValue(paramAnn, "readonly", false); // inverted! + p.expression = getStringValue(paramAnn, "property", ""); + if (!p.expression.isEmpty() && !p.expression.startsWith("${")) { + p.expression = "${" + p.expression + "}"; + } + p.defaultValue = getStringValue(paramAnn, "defaultValue", ""); + p.description = doc.description; + p.since = doc.since; + p.deprecated = doc.deprecated; + result.add(p); + } + current = superTypeElement(current); + } + return result; + } + + private void writeParameter(XMLStreamWriter xml, ParameterInfo p) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("parameter"); + xml.writeCharacters("\n"); + writeElement(xml, "name", p.name, 10); + writeElementIfNotEmpty(xml, "alias", p.alias, 10); + writeElement(xml, "type", p.type, 10); + writeElement(xml, "required", String.valueOf(p.required), 10); + writeElement(xml, "editable", String.valueOf(p.editable), 10); + writeElementIfNotEmpty(xml, "description", p.description, 10); + writeElementIfNotEmpty(xml, "expression", p.expression, 10); + writeElementIfNotEmpty(xml, "defaultValue", p.defaultValue, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // parameter + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Resolution collection + // ------------------------------------------------------------------------- + + private List<ResolutionInfo> collectResolutions(TypeElement mojoType) { + List<ResolutionInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD) { + continue; + } + AnnotationMirror resAnn = findAnnotation(enclosed, RESOLUTION_ANN); + if (resAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + if (seen.contains(fieldName)) { + continue; + } + seen.add(fieldName); + + ResolutionInfo r = new ResolutionInfo(); + r.field = fieldName; + r.pathScope = getStringValue(resAnn, "pathScope", ""); + r.requestType = getStringValue(resAnn, "requestType", ""); + result.add(r); + } + current = superTypeElement(current); + } + return result; + } + + private void writeResolution(XMLStreamWriter xml, ResolutionInfo r) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("resolution"); + xml.writeCharacters("\n"); + writeElement(xml, "field", r.field, 10); + writeElementIfNotEmpty(xml, "pathScope", r.pathScope, 10); + writeElementIfNotEmpty(xml, "requestType", r.requestType, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // resolution + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // @After / @Afters collection + // ------------------------------------------------------------------------- + + private List<AfterInfo> collectAfters(TypeElement mojoType) { + List<AfterInfo> result = new ArrayList<>(); + // Check for @Afters (container) first + AnnotationMirror aftersAnn = findAnnotation(mojoType, AFTERS_ANN); + if (aftersAnn != null) { + AnnotationValue valueAttr = getAnnotationValue(aftersAnn, "value"); + if (valueAttr != null && valueAttr.getValue() instanceof List<?> list) { + for (Object item : list) { + if (item instanceof AnnotationValue av && av.getValue() instanceof AnnotationMirror afterMirror) { + result.add(extractAfter(afterMirror)); + } + } + } + } + // Also check for a single @After directly on the class + AnnotationMirror afterAnn = findAnnotation(mojoType, AFTER_ANN); + if (afterAnn != null && aftersAnn == null) { + result.add(extractAfter(afterAnn)); + } + return result; + } + + private AfterInfo extractAfter(AnnotationMirror ann) { + AfterInfo a = new AfterInfo(); + a.phase = getStringValue(ann, "phase", ""); + a.type = getEnumValue(ann, "type", "PROJECT"); + a.scope = getStringValue(ann, "scope", ""); + return a; + } + + private void writeAfterLink(XMLStreamWriter xml, AfterInfo a) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("afterLink"); + xml.writeCharacters("\n"); + writeElementIfNotEmpty(xml, "phase", a.phase, 10); + writeElement(xml, "type", a.type, 10); + writeElementIfNotEmpty(xml, "scope", a.scope, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLink + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Factory class generation + // ------------------------------------------------------------------------- + + private void generateFactory(TypeElement mojoType) throws IOException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + + // Options may not be set yet at factory-generation time (first round before processingOver) + // — use placeholders that get resolved when plugin.xml is written, but the @Named value + // must be stable. If GAV options are missing we skip factory generation; validateOptions() + // will emit the proper error in processingOver(). + if (groupId == null || artifactId == null || version == null) { + return; + } + + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : ""; + String simpleName = mojoType.getSimpleName().toString(); + String factorySimpleName = simpleName + "$MojoFactory"; + String factoryFqn = pkg.isEmpty() ? factorySimpleName : pkg + "." + factorySimpleName; + String roleHint = groupId + ":" + artifactId + ":" + version + ":" + goal; + + // Collect non-private constructors to replicate in the factory + List<ExecutableElement> ctors = new ArrayList<>(); + for (Element enc : mojoType.getEnclosedElements()) { + if (enc.getKind() == ElementKind.CONSTRUCTOR) { + ExecutableElement ctor = (ExecutableElement) enc; + if (!ctor.getModifiers().contains(Modifier.PRIVATE)) { + ctors.add(ctor); + } + } + } + + JavaFileObject src = processingEnv.getFiler().createSourceFile(factoryFqn, mojoType); + try (PrintWriter pw = new PrintWriter(src.openWriter())) { + writeLicenseHeader(pw); + if (!pkg.isEmpty()) { + pw.println("package " + pkg + ";"); + pw.println(); + } + pw.println("/**"); + pw.println(" * DI factory for the {@code " + goal + "} mojo."); + pw.println(" * Generated by {@link org.apache.maven.plugin.processor.MojoDescriptorProcessor}."); + pw.println(" *"); + pw.println(" * @since 4.1.0"); + pw.println(" */"); + pw.println("@org.apache.maven.api.di.Named(\"" + roleHint + "\")"); + pw.println("public class " + factorySimpleName + " extends " + simpleName + " {"); + pw.println(); + + if (!ctors.isEmpty() && !hasOnlyDefaultCtor(ctors)) { + for (ExecutableElement ctor : ctors) { + if (ctor.getParameters().isEmpty()) { + continue; // default ctor — implicit delegation to super + } + pw.print(" public " + factorySimpleName + "("); + List<? extends VariableElement> params = ctor.getParameters(); + StringBuilder args = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + VariableElement param = params.get(i); + String typeName = param.asType().toString(); + String paramName = param.getSimpleName().toString(); + if (i > 0) { + pw.print(", "); + args.append(", "); + } + pw.print(typeName + " " + paramName); + args.append(paramName); + } + pw.println(") {"); + pw.println(" super(" + args + ");"); + pw.println(" }"); + pw.println(); + } + } + + pw.println("}"); + } + + generatedFactoryNames.add(factoryFqn); + note("Generated factory " + factoryFqn + " for goal '" + goal + "'"); + } + + private boolean hasOnlyDefaultCtor(List<ExecutableElement> ctors) { + return ctors.size() == 1 && ctors.get(0).getParameters().isEmpty(); + } + + // ------------------------------------------------------------------------- + // Javadoc parsing + // ------------------------------------------------------------------------- + + private static JavadocInfo parseJavadoc(String raw) { + JavadocInfo info = new JavadocInfo(); + if (raw == null || raw.isBlank()) { + return info; + } + // Split on block Javadoc tags (tags at start of a line, possibly with leading whitespace) + String[] parts = raw.split("(?m)^\\s*@"); + String body = parts[0]; + + // Strip inline tags like {@code ...}, {@link ...} etc. — replace with their text content + body = body.replaceAll("\\{@\\w+\\s+([^}]*)}", "$1"); + // Remove HTML tags + body = body.replaceAll("<[^>]+>", ""); + info.description = normalizeWhitespace(body); + + // Parse @since, @deprecated from remaining parts + for (int i = 1; i < parts.length; i++) { + String tag = parts[i].trim(); + if (tag.startsWith("since")) { + info.since = tag.substring("since".length()).trim().replaceAll("\\s+", " "); + } else if (tag.startsWith("deprecated")) { + info.deprecated = tag.substring("deprecated".length()).trim().replaceAll("\\s+", " "); + } + } + return info; + } + + private static String normalizeWhitespace(String s) { + if (s == null) { + return null; + } + String trimmed = s.strip(); + if (trimmed.isEmpty()) { + return null; + } + // Collapse internal whitespace runs to a single space + return trimmed.replaceAll("\\s+", " "); + } + + // ------------------------------------------------------------------------- + // Annotation mirror utilities + // ------------------------------------------------------------------------- + + private static AnnotationMirror findAnnotation(Element element, String annotationFqn) { + for (AnnotationMirror am : element.getAnnotationMirrors()) { + if (am.getAnnotationType().toString().equals(annotationFqn)) { + return am; + } + } + return null; + } + + private static AnnotationValue getAnnotationValue(AnnotationMirror am, String methodName) { + for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : + am.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().toString().equals(methodName)) { + return entry.getValue(); + } + } + return null; + } + + private static String getStringValue(AnnotationMirror am, String methodName, String defaultValue) { + AnnotationValue av = getAnnotationValue(am, methodName); + if (av == null) { + return defaultValue; + } + Object v = av.getValue(); + return v != null ? v.toString() : defaultValue; + } + + private static boolean getBooleanValue(AnnotationMirror am, String methodName, boolean defaultValue) { + AnnotationValue av = getAnnotationValue(am, methodName); + if (av == null) { + return defaultValue; + } + Object v = av.getValue(); + return v instanceof Boolean ? (Boolean) v : defaultValue; + } + + /** + * Returns the simple name of an enum value from an annotation attribute. + * Enum values in annotation mirrors are represented as {@link VariableElement}. + */ + private static String getEnumValue(AnnotationMirror am, String methodName, String defaultValue) { + AnnotationValue av = getAnnotationValue(am, methodName); + if (av == null) { + return defaultValue; + } + Object v = av.getValue(); + if (v instanceof VariableElement ve) { + return ve.getSimpleName().toString(); + } + return defaultValue; + } + + // ------------------------------------------------------------------------- + // Type hierarchy walking + // ------------------------------------------------------------------------- + + private TypeElement superTypeElement(TypeElement te) { + TypeMirror superMirror = te.getSuperclass(); + if (superMirror == null || superMirror.getKind() == TypeKind.NONE) { + return null; + } + if (superMirror instanceof DeclaredType dt && dt.asElement() instanceof TypeElement superType) { + return isObject(superType) ? null : superType; + } + return null; + } + + private static boolean isObject(TypeElement te) { + return "java.lang.Object".equals(te.getQualifiedName().toString()); + } + + // ------------------------------------------------------------------------- + // XML writing helpers + // ------------------------------------------------------------------------- + + private static void writeElement(XMLStreamWriter xml, String tag, String value) throws XMLStreamException { + writeElement(xml, tag, value, 4); + } Review Comment: 🧹 **Dead code.** The 3-argument `writeElement(XMLStreamWriter, String, String)` overload (hardcoded indent=4) is never called directly — all call sites use the explicit-indent 4-argument version. Remove it. Previously raised — still not fixed. ```suggestion ``` ########## api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java: ########## @@ -0,0 +1,876 @@ +/* + * 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.maven.plugin.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedOptions; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml}) + * and DI factory classes at compile time from {@code @Mojo}-annotated classes. + * + * <p>This processor requires the following compiler options (via {@code -A}): + * <ul> + * <li>{@code maven.plugin.groupId} — the plugin's group ID</li> + * <li>{@code maven.plugin.artifactId} — the plugin's artifact ID</li> + * <li>{@code maven.plugin.version} — the plugin's version</li> + * <li>{@code maven.plugin.goalPrefix} — optional; derived from artifactId if absent</li> + * </ul> + * + * <p>The generated {@code plugin.xml} follows the {@code http://maven.apache.org/PLUGIN/2.0.0} namespace. + * One factory class is generated per mojo; the factory is annotated with + * {@code @org.apache.maven.api.di.Named("groupId:artifactId:version:goal")} so that the DI container + * can locate it by the role-hint used in {@code DefaultMavenPluginManager.loadV4Mojo()}. + * The {@code DiIndexProcessor} (from {@code maven-api-di}) picks up the {@code @Named} factory in the + * next processing round and registers it in {@code META-INF/maven/org.apache.maven.api.di.Inject}. + * + * <p>Design notes: + * <ul> + * <li>Returns {@code false} from {@code process()} — does not claim {@code @Mojo}; other processors + * and IDEs can still see it.</li> + * <li>Annotation references are string-based — no compile dependency on {@code maven-api-core} or + * {@code maven-api-di}; mirror names are compared as strings.</li> + * <li>Full type hierarchy is walked to collect {@code @Parameter} and {@code @Resolution} fields, + * including those declared in parent classes from dependency JARs.</li> + * <li>Javadoc is extracted via {@code Elements.getDocComment()} — only available for source elements; + * inherited parameters from binary JARs will have {@code null} descriptions.</li> + * </ul> + * + * @since 4.1.0 + */ +@SupportedAnnotationTypes("org.apache.maven.api.plugin.annotations.Mojo") +@SupportedOptions({ + MojoDescriptorProcessor.OPT_GROUP_ID, + MojoDescriptorProcessor.OPT_ARTIFACT_ID, + MojoDescriptorProcessor.OPT_VERSION, + MojoDescriptorProcessor.OPT_GOAL_PREFIX +}) +public class MojoDescriptorProcessor extends AbstractProcessor { + + static final String OPT_GROUP_ID = "maven.plugin.groupId"; + static final String OPT_ARTIFACT_ID = "maven.plugin.artifactId"; + static final String OPT_VERSION = "maven.plugin.version"; + static final String OPT_GOAL_PREFIX = "maven.plugin.goalPrefix"; + + private static final String MOJO_ANN = "org.apache.maven.api.plugin.annotations.Mojo"; + private static final String PARAMETER_ANN = "org.apache.maven.api.plugin.annotations.Parameter"; + private static final String RESOLUTION_ANN = "org.apache.maven.api.plugin.annotations.Resolution"; + private static final String EXECUTE_ANN = "org.apache.maven.api.plugin.annotations.Execute"; + private static final String AFTER_ANN = "org.apache.maven.api.plugin.annotations.After"; + private static final String AFTERS_ANN = "org.apache.maven.api.plugin.annotations.Afters"; + + private static final String PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0"; + + /** Mojos collected across all rounds (FQN → TypeElement). */ + private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>(); + + /** Factory class names generated in previous rounds — skip re-processing them. */ + private final Set<String> generatedFactoryNames = new LinkedHashSet<>(); + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { + for (TypeElement annotation : annotations) { + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (!(element instanceof TypeElement typeElement)) { + continue; + } + String fqn = typeElement.getQualifiedName().toString(); + // Skip factory classes we generated in a previous round + if (generatedFactoryNames.contains(fqn)) { + continue; + } + // Validate: must be a concrete class + if (typeElement.getKind() == ElementKind.INTERFACE) { + processingEnv + .getMessager() + .printMessage(Diagnostic.Kind.ERROR, "@Mojo must not be placed on an interface", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "@Mojo must not be placed on an abstract class", element); + continue; + } + if (!mojoElements.containsKey(fqn)) { + mojoElements.put(fqn, typeElement); + // Generate factory immediately in this round so DiIndexProcessor can pick it + // up in the next round. Factories generated in processingOver() are invisible + // to subsequent processors because there are no more rounds after the last one. + try { + generateFactory(typeElement); + } catch (IOException e) { + logError("Failed to generate factory for " + fqn, e); + } + } + } + } + + if (roundEnv.processingOver()) { + if (mojoElements.isEmpty()) { + return false; + } + if (!validateOptions()) { + return false; + } + try { + writePluginXml(); + } catch (Exception e) { + logError("Failed to generate plugin.xml", e); + } + } + + return false; // do not claim the annotation + } + + // ------------------------------------------------------------------------- + // Option validation + // ------------------------------------------------------------------------- + + /** + * Validates that all required compiler options are present. + * + * @return {@code true} if all required options are present, {@code false} otherwise + */ + private boolean validateOptions() { + boolean ok = true; + for (String opt : new String[] {OPT_GROUP_ID, OPT_ARTIFACT_ID, OPT_VERSION}) { + if (getOption(opt) == null) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Required compiler option -A" + opt + " is missing. " + + "Add it to the maven-compiler-plugin configuration."); + ok = false; + } + } + return ok; + } + + private String getOption(String key) { + return processingEnv.getOptions().get(key); + } + + private String getOptionOrDefault(String key, String defaultValue) { + String v = getOption(key); + return (v != null && !v.isEmpty()) ? v : defaultValue; + } + + // ------------------------------------------------------------------------- + // plugin.xml generation + // ------------------------------------------------------------------------- + + private void writePluginXml() throws IOException, XMLStreamException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + String goalPrefix = getOptionOrDefault(OPT_GOAL_PREFIX, deriveGoalPrefix(artifactId)); + + FileObject out = + processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml"); + + try (Writer w = out.openWriter()) { + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + XMLStreamWriter xml = factory.createXMLStreamWriter(w); + try { + xml.writeStartDocument("UTF-8", "1.0"); + xml.writeCharacters("\n"); + xml.writeStartElement("plugin"); + xml.writeDefaultNamespace(PLUGIN_NS); + xml.writeCharacters("\n"); + + writeElement(xml, "name", artifactId, 2); + writeElement(xml, "groupId", groupId, 2); + writeElement(xml, "artifactId", artifactId, 2); + writeElement(xml, "version", version, 2); + writeElement(xml, "goalPrefix", goalPrefix, 2); + + xml.writeCharacters(" "); + xml.writeStartElement("mojos"); + xml.writeCharacters("\n"); + + for (TypeElement mojoType : mojoElements.values()) { + writeMojo(xml, mojoType, groupId, artifactId, version); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojos + xml.writeCharacters("\n"); + xml.writeEndElement(); // plugin + xml.writeCharacters("\n"); + xml.writeEndDocument(); + } finally { + xml.close(); + } + } + note("Generated META-INF/maven/plugin.xml with " + mojoElements.size() + " mojo(s)"); + } + + private void writeMojo(XMLStreamWriter xml, TypeElement mojoType, String groupId, String artifactId, String version) + throws XMLStreamException { + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType); + return; + } + + // Javadoc for the mojo class + JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType)); + + xml.writeCharacters(" "); + xml.writeStartElement("mojo"); + xml.writeCharacters("\n"); + + writeElement(xml, "goal", goal, 6); + writeElementIfNotEmpty(xml, "phase", getStringValue(mojoAnn, "defaultPhase", ""), 6); + writeElement(xml, "implementation", fqn, 6); + writeElement(xml, "language", "java", 6); + writeElementIfNotEmpty(xml, "description", mojoDoc.description, 6); + writeElementIfNotEmpty(xml, "since", mojoDoc.since, 6); + writeElementIfNotEmpty(xml, "deprecated", mojoDoc.deprecated, 6); + writeElement(xml, "projectRequired", String.valueOf(getBooleanValue(mojoAnn, "projectRequired", true)), 6); + writeElement(xml, "onlineRequired", String.valueOf(getBooleanValue(mojoAnn, "onlineRequired", false)), 6); + writeElement(xml, "aggregator", String.valueOf(getBooleanValue(mojoAnn, "aggregator", false)), 6); + writeElementIfNotEmpty(xml, "configurator", getStringValue(mojoAnn, "configurator", ""), 6); + + // @Execute + AnnotationMirror executeAnn = findAnnotation(mojoType, EXECUTE_ANN); + if (executeAnn != null) { + writeElementIfNotEmpty(xml, "executePhase", getStringValue(executeAnn, "phase", ""), 6); + writeElementIfNotEmpty(xml, "executeGoal", getStringValue(executeAnn, "goal", ""), 6); + writeElementIfNotEmpty(xml, "executeLifecycle", getStringValue(executeAnn, "lifecycle", ""), 6); + } + + // dependencyResolution / dependencyCollection from @Mojo + writeElementIfNotEmpty( + xml, "dependencyResolution", getStringValue(mojoAnn, "dependencyResolutionPathScopes", ""), 6); + boolean depCollection = getBooleanValue(mojoAnn, "dependencyCollection", false); + if (depCollection) { + writeElement(xml, "dependencyCollection", "true", 6); + } + + // @Parameter fields — walk the full type hierarchy + List<ParameterInfo> params = collectParameters(mojoType); + if (!params.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("parameters"); + xml.writeCharacters("\n"); + for (ParameterInfo p : params) { + writeParameter(xml, p); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // parameters + xml.writeCharacters("\n"); + } + + // @Resolution fields + List<ResolutionInfo> resolutions = collectResolutions(mojoType); + if (!resolutions.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("resolutions"); + xml.writeCharacters("\n"); + for (ResolutionInfo r : resolutions) { + writeResolution(xml, r); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // resolutions + xml.writeCharacters("\n"); + } + + // @After / @Afters + List<AfterInfo> afters = collectAfters(mojoType); + if (!afters.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("afterLinks"); + xml.writeCharacters("\n"); + for (AfterInfo a : afters) { + writeAfterLink(xml, a); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLinks + xml.writeCharacters("\n"); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojo + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Parameter collection + // ------------------------------------------------------------------------- + + private List<ParameterInfo> collectParameters(TypeElement mojoType) { + List<ParameterInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) { + continue; + } + AnnotationMirror paramAnn = findAnnotation(enclosed, PARAMETER_ANN); + if (paramAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + String paramName = getStringValue(paramAnn, "name", ""); + if (paramName.isEmpty()) { + paramName = fieldName; + } + if (seen.contains(paramName)) { + continue; // subclass overrides parent + } + seen.add(paramName); + + String type; + if (enclosed.getKind() == ElementKind.FIELD) { + type = ((VariableElement) enclosed).asType().toString(); + } else { + // method: use parameter type of setter, or return type of getter + ExecutableElement method = (ExecutableElement) enclosed; + if (!method.getParameters().isEmpty()) { + type = method.getParameters().get(0).asType().toString(); + } else { + type = method.getReturnType().toString(); + } + } + + JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed)); + + ParameterInfo p = new ParameterInfo(); + p.name = paramName; + p.alias = getStringValue(paramAnn, "alias", ""); + p.type = type; + p.required = getBooleanValue(paramAnn, "required", false); + p.editable = !getBooleanValue(paramAnn, "readonly", false); // inverted! + p.expression = getStringValue(paramAnn, "property", ""); + if (!p.expression.isEmpty() && !p.expression.startsWith("${")) { + p.expression = "${" + p.expression + "}"; + } + p.defaultValue = getStringValue(paramAnn, "defaultValue", ""); + p.description = doc.description; + p.since = doc.since; + p.deprecated = doc.deprecated; + result.add(p); + } + current = superTypeElement(current); + } + return result; + } + + private void writeParameter(XMLStreamWriter xml, ParameterInfo p) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("parameter"); + xml.writeCharacters("\n"); + writeElement(xml, "name", p.name, 10); + writeElementIfNotEmpty(xml, "alias", p.alias, 10); + writeElement(xml, "type", p.type, 10); + writeElement(xml, "required", String.valueOf(p.required), 10); + writeElement(xml, "editable", String.valueOf(p.editable), 10); + writeElementIfNotEmpty(xml, "description", p.description, 10); + writeElementIfNotEmpty(xml, "expression", p.expression, 10); + writeElementIfNotEmpty(xml, "defaultValue", p.defaultValue, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // parameter + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Resolution collection + // ------------------------------------------------------------------------- + + private List<ResolutionInfo> collectResolutions(TypeElement mojoType) { + List<ResolutionInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD) { + continue; + } + AnnotationMirror resAnn = findAnnotation(enclosed, RESOLUTION_ANN); + if (resAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + if (seen.contains(fieldName)) { + continue; + } + seen.add(fieldName); + + ResolutionInfo r = new ResolutionInfo(); + r.field = fieldName; + r.pathScope = getStringValue(resAnn, "pathScope", ""); + r.requestType = getStringValue(resAnn, "requestType", ""); + result.add(r); + } + current = superTypeElement(current); + } + return result; + } + + private void writeResolution(XMLStreamWriter xml, ResolutionInfo r) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("resolution"); + xml.writeCharacters("\n"); + writeElement(xml, "field", r.field, 10); + writeElementIfNotEmpty(xml, "pathScope", r.pathScope, 10); + writeElementIfNotEmpty(xml, "requestType", r.requestType, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // resolution + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // @After / @Afters collection + // ------------------------------------------------------------------------- + + private List<AfterInfo> collectAfters(TypeElement mojoType) { + List<AfterInfo> result = new ArrayList<>(); + // Check for @Afters (container) first + AnnotationMirror aftersAnn = findAnnotation(mojoType, AFTERS_ANN); + if (aftersAnn != null) { + AnnotationValue valueAttr = getAnnotationValue(aftersAnn, "value"); + if (valueAttr != null && valueAttr.getValue() instanceof List<?> list) { + for (Object item : list) { + if (item instanceof AnnotationValue av && av.getValue() instanceof AnnotationMirror afterMirror) { + result.add(extractAfter(afterMirror)); + } + } + } + } + // Also check for a single @After directly on the class + AnnotationMirror afterAnn = findAnnotation(mojoType, AFTER_ANN); + if (afterAnn != null && aftersAnn == null) { + result.add(extractAfter(afterAnn)); + } + return result; + } + + private AfterInfo extractAfter(AnnotationMirror ann) { + AfterInfo a = new AfterInfo(); + a.phase = getStringValue(ann, "phase", ""); + a.type = getEnumValue(ann, "type", "PROJECT"); + a.scope = getStringValue(ann, "scope", ""); + return a; + } + + private void writeAfterLink(XMLStreamWriter xml, AfterInfo a) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("afterLink"); + xml.writeCharacters("\n"); + writeElementIfNotEmpty(xml, "phase", a.phase, 10); + writeElement(xml, "type", a.type, 10); + writeElementIfNotEmpty(xml, "scope", a.scope, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLink + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Factory class generation + // ------------------------------------------------------------------------- + + private void generateFactory(TypeElement mojoType) throws IOException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + + // Options may not be set yet at factory-generation time (first round before processingOver) + // — use placeholders that get resolved when plugin.xml is written, but the @Named value + // must be stable. If GAV options are missing we skip factory generation; validateOptions() + // will emit the proper error in processingOver(). + if (groupId == null || artifactId == null || version == null) { + return; + } + + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : ""; + String simpleName = mojoType.getSimpleName().toString(); + String factorySimpleName = simpleName + "$MojoFactory"; Review Comment: 💡 **`$` in generated class names.** `MyMojo$MojoFactory` uses `$`, which the JLS §3.8 reserves for compiler-generated identifiers. Tools like ProGuard, ASM-based frameworks, and some IDEs interpret `$` as an inner-class separator and may misclassify these factories as inner classes of `MyMojo`. Previously raised — still not fixed. Consider `MyMojo__MojoFactory`. ```suggestion String factorySimpleName = simpleName + "__MojoFactory"; ``` ########## api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java: ########## @@ -0,0 +1,876 @@ +/* + * 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.maven.plugin.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedOptions; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml}) + * and DI factory classes at compile time from {@code @Mojo}-annotated classes. + * + * <p>This processor requires the following compiler options (via {@code -A}): + * <ul> + * <li>{@code maven.plugin.groupId} — the plugin's group ID</li> + * <li>{@code maven.plugin.artifactId} — the plugin's artifact ID</li> + * <li>{@code maven.plugin.version} — the plugin's version</li> + * <li>{@code maven.plugin.goalPrefix} — optional; derived from artifactId if absent</li> + * </ul> + * + * <p>The generated {@code plugin.xml} follows the {@code http://maven.apache.org/PLUGIN/2.0.0} namespace. + * One factory class is generated per mojo; the factory is annotated with + * {@code @org.apache.maven.api.di.Named("groupId:artifactId:version:goal")} so that the DI container + * can locate it by the role-hint used in {@code DefaultMavenPluginManager.loadV4Mojo()}. + * The {@code DiIndexProcessor} (from {@code maven-api-di}) picks up the {@code @Named} factory in the + * next processing round and registers it in {@code META-INF/maven/org.apache.maven.api.di.Inject}. + * + * <p>Design notes: + * <ul> + * <li>Returns {@code false} from {@code process()} — does not claim {@code @Mojo}; other processors + * and IDEs can still see it.</li> + * <li>Annotation references are string-based — no compile dependency on {@code maven-api-core} or + * {@code maven-api-di}; mirror names are compared as strings.</li> + * <li>Full type hierarchy is walked to collect {@code @Parameter} and {@code @Resolution} fields, + * including those declared in parent classes from dependency JARs.</li> + * <li>Javadoc is extracted via {@code Elements.getDocComment()} — only available for source elements; + * inherited parameters from binary JARs will have {@code null} descriptions.</li> + * </ul> + * + * @since 4.1.0 + */ +@SupportedAnnotationTypes("org.apache.maven.api.plugin.annotations.Mojo") +@SupportedOptions({ + MojoDescriptorProcessor.OPT_GROUP_ID, + MojoDescriptorProcessor.OPT_ARTIFACT_ID, + MojoDescriptorProcessor.OPT_VERSION, + MojoDescriptorProcessor.OPT_GOAL_PREFIX +}) +public class MojoDescriptorProcessor extends AbstractProcessor { + + static final String OPT_GROUP_ID = "maven.plugin.groupId"; + static final String OPT_ARTIFACT_ID = "maven.plugin.artifactId"; + static final String OPT_VERSION = "maven.plugin.version"; + static final String OPT_GOAL_PREFIX = "maven.plugin.goalPrefix"; + + private static final String MOJO_ANN = "org.apache.maven.api.plugin.annotations.Mojo"; + private static final String PARAMETER_ANN = "org.apache.maven.api.plugin.annotations.Parameter"; + private static final String RESOLUTION_ANN = "org.apache.maven.api.plugin.annotations.Resolution"; + private static final String EXECUTE_ANN = "org.apache.maven.api.plugin.annotations.Execute"; + private static final String AFTER_ANN = "org.apache.maven.api.plugin.annotations.After"; + private static final String AFTERS_ANN = "org.apache.maven.api.plugin.annotations.Afters"; + + private static final String PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0"; + + /** Mojos collected across all rounds (FQN → TypeElement). */ + private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>(); + + /** Factory class names generated in previous rounds — skip re-processing them. */ + private final Set<String> generatedFactoryNames = new LinkedHashSet<>(); + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { + for (TypeElement annotation : annotations) { + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (!(element instanceof TypeElement typeElement)) { + continue; + } + String fqn = typeElement.getQualifiedName().toString(); + // Skip factory classes we generated in a previous round + if (generatedFactoryNames.contains(fqn)) { + continue; + } + // Validate: must be a concrete class + if (typeElement.getKind() == ElementKind.INTERFACE) { + processingEnv + .getMessager() + .printMessage(Diagnostic.Kind.ERROR, "@Mojo must not be placed on an interface", element); + continue; + } + if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "@Mojo must not be placed on an abstract class", element); + continue; + } + if (!mojoElements.containsKey(fqn)) { + mojoElements.put(fqn, typeElement); + // Generate factory immediately in this round so DiIndexProcessor can pick it + // up in the next round. Factories generated in processingOver() are invisible + // to subsequent processors because there are no more rounds after the last one. + try { + generateFactory(typeElement); + } catch (IOException e) { + logError("Failed to generate factory for " + fqn, e); + } + } + } + } + + if (roundEnv.processingOver()) { + if (mojoElements.isEmpty()) { + return false; + } + if (!validateOptions()) { + return false; + } + try { + writePluginXml(); + } catch (Exception e) { + logError("Failed to generate plugin.xml", e); + } + } + + return false; // do not claim the annotation + } + + // ------------------------------------------------------------------------- + // Option validation + // ------------------------------------------------------------------------- + + /** + * Validates that all required compiler options are present. + * + * @return {@code true} if all required options are present, {@code false} otherwise + */ + private boolean validateOptions() { + boolean ok = true; + for (String opt : new String[] {OPT_GROUP_ID, OPT_ARTIFACT_ID, OPT_VERSION}) { + if (getOption(opt) == null) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Required compiler option -A" + opt + " is missing. " + + "Add it to the maven-compiler-plugin configuration."); + ok = false; + } + } + return ok; + } + + private String getOption(String key) { + return processingEnv.getOptions().get(key); + } + + private String getOptionOrDefault(String key, String defaultValue) { + String v = getOption(key); + return (v != null && !v.isEmpty()) ? v : defaultValue; + } + + // ------------------------------------------------------------------------- + // plugin.xml generation + // ------------------------------------------------------------------------- + + private void writePluginXml() throws IOException, XMLStreamException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + String goalPrefix = getOptionOrDefault(OPT_GOAL_PREFIX, deriveGoalPrefix(artifactId)); + + FileObject out = + processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml"); + + try (Writer w = out.openWriter()) { + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + XMLStreamWriter xml = factory.createXMLStreamWriter(w); + try { + xml.writeStartDocument("UTF-8", "1.0"); + xml.writeCharacters("\n"); + xml.writeStartElement("plugin"); + xml.writeDefaultNamespace(PLUGIN_NS); + xml.writeCharacters("\n"); + + writeElement(xml, "name", artifactId, 2); + writeElement(xml, "groupId", groupId, 2); + writeElement(xml, "artifactId", artifactId, 2); + writeElement(xml, "version", version, 2); + writeElement(xml, "goalPrefix", goalPrefix, 2); + + xml.writeCharacters(" "); + xml.writeStartElement("mojos"); + xml.writeCharacters("\n"); + + for (TypeElement mojoType : mojoElements.values()) { + writeMojo(xml, mojoType, groupId, artifactId, version); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojos + xml.writeCharacters("\n"); + xml.writeEndElement(); // plugin + xml.writeCharacters("\n"); + xml.writeEndDocument(); + } finally { + xml.close(); + } + } + note("Generated META-INF/maven/plugin.xml with " + mojoElements.size() + " mojo(s)"); + } + + private void writeMojo(XMLStreamWriter xml, TypeElement mojoType, String groupId, String artifactId, String version) + throws XMLStreamException { + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType); + return; + } + + // Javadoc for the mojo class + JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType)); + + xml.writeCharacters(" "); + xml.writeStartElement("mojo"); + xml.writeCharacters("\n"); + + writeElement(xml, "goal", goal, 6); + writeElementIfNotEmpty(xml, "phase", getStringValue(mojoAnn, "defaultPhase", ""), 6); + writeElement(xml, "implementation", fqn, 6); + writeElement(xml, "language", "java", 6); + writeElementIfNotEmpty(xml, "description", mojoDoc.description, 6); + writeElementIfNotEmpty(xml, "since", mojoDoc.since, 6); + writeElementIfNotEmpty(xml, "deprecated", mojoDoc.deprecated, 6); + writeElement(xml, "projectRequired", String.valueOf(getBooleanValue(mojoAnn, "projectRequired", true)), 6); + writeElement(xml, "onlineRequired", String.valueOf(getBooleanValue(mojoAnn, "onlineRequired", false)), 6); + writeElement(xml, "aggregator", String.valueOf(getBooleanValue(mojoAnn, "aggregator", false)), 6); + writeElementIfNotEmpty(xml, "configurator", getStringValue(mojoAnn, "configurator", ""), 6); + + // @Execute + AnnotationMirror executeAnn = findAnnotation(mojoType, EXECUTE_ANN); + if (executeAnn != null) { + writeElementIfNotEmpty(xml, "executePhase", getStringValue(executeAnn, "phase", ""), 6); + writeElementIfNotEmpty(xml, "executeGoal", getStringValue(executeAnn, "goal", ""), 6); + writeElementIfNotEmpty(xml, "executeLifecycle", getStringValue(executeAnn, "lifecycle", ""), 6); + } + + // dependencyResolution / dependencyCollection from @Mojo + writeElementIfNotEmpty( + xml, "dependencyResolution", getStringValue(mojoAnn, "dependencyResolutionPathScopes", ""), 6); + boolean depCollection = getBooleanValue(mojoAnn, "dependencyCollection", false); + if (depCollection) { + writeElement(xml, "dependencyCollection", "true", 6); + } + + // @Parameter fields — walk the full type hierarchy + List<ParameterInfo> params = collectParameters(mojoType); + if (!params.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("parameters"); + xml.writeCharacters("\n"); + for (ParameterInfo p : params) { + writeParameter(xml, p); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // parameters + xml.writeCharacters("\n"); + } + + // @Resolution fields + List<ResolutionInfo> resolutions = collectResolutions(mojoType); + if (!resolutions.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("resolutions"); + xml.writeCharacters("\n"); + for (ResolutionInfo r : resolutions) { + writeResolution(xml, r); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // resolutions + xml.writeCharacters("\n"); + } + + // @After / @Afters + List<AfterInfo> afters = collectAfters(mojoType); + if (!afters.isEmpty()) { + xml.writeCharacters(" "); + xml.writeStartElement("afterLinks"); + xml.writeCharacters("\n"); + for (AfterInfo a : afters) { + writeAfterLink(xml, a); + } + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLinks + xml.writeCharacters("\n"); + } + + xml.writeCharacters(" "); + xml.writeEndElement(); // mojo + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Parameter collection + // ------------------------------------------------------------------------- + + private List<ParameterInfo> collectParameters(TypeElement mojoType) { + List<ParameterInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) { + continue; + } + AnnotationMirror paramAnn = findAnnotation(enclosed, PARAMETER_ANN); + if (paramAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + String paramName = getStringValue(paramAnn, "name", ""); + if (paramName.isEmpty()) { + paramName = fieldName; + } + if (seen.contains(paramName)) { + continue; // subclass overrides parent + } + seen.add(paramName); + + String type; + if (enclosed.getKind() == ElementKind.FIELD) { + type = ((VariableElement) enclosed).asType().toString(); + } else { + // method: use parameter type of setter, or return type of getter + ExecutableElement method = (ExecutableElement) enclosed; + if (!method.getParameters().isEmpty()) { + type = method.getParameters().get(0).asType().toString(); + } else { + type = method.getReturnType().toString(); + } + } + + JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed)); + + ParameterInfo p = new ParameterInfo(); + p.name = paramName; + p.alias = getStringValue(paramAnn, "alias", ""); + p.type = type; + p.required = getBooleanValue(paramAnn, "required", false); + p.editable = !getBooleanValue(paramAnn, "readonly", false); // inverted! + p.expression = getStringValue(paramAnn, "property", ""); + if (!p.expression.isEmpty() && !p.expression.startsWith("${")) { + p.expression = "${" + p.expression + "}"; + } + p.defaultValue = getStringValue(paramAnn, "defaultValue", ""); + p.description = doc.description; + p.since = doc.since; + p.deprecated = doc.deprecated; + result.add(p); + } + current = superTypeElement(current); + } + return result; + } + + private void writeParameter(XMLStreamWriter xml, ParameterInfo p) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("parameter"); + xml.writeCharacters("\n"); + writeElement(xml, "name", p.name, 10); + writeElementIfNotEmpty(xml, "alias", p.alias, 10); + writeElement(xml, "type", p.type, 10); + writeElement(xml, "required", String.valueOf(p.required), 10); + writeElement(xml, "editable", String.valueOf(p.editable), 10); + writeElementIfNotEmpty(xml, "description", p.description, 10); + writeElementIfNotEmpty(xml, "expression", p.expression, 10); + writeElementIfNotEmpty(xml, "defaultValue", p.defaultValue, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // parameter + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Resolution collection + // ------------------------------------------------------------------------- + + private List<ResolutionInfo> collectResolutions(TypeElement mojoType) { + List<ResolutionInfo> result = new ArrayList<>(); + Set<String> seen = new LinkedHashSet<>(); + TypeElement current = mojoType; + while (current != null && !isObject(current)) { + for (Element enclosed : current.getEnclosedElements()) { + if (enclosed.getKind() != ElementKind.FIELD) { + continue; + } + AnnotationMirror resAnn = findAnnotation(enclosed, RESOLUTION_ANN); + if (resAnn == null) { + continue; + } + String fieldName = enclosed.getSimpleName().toString(); + if (seen.contains(fieldName)) { + continue; + } + seen.add(fieldName); + + ResolutionInfo r = new ResolutionInfo(); + r.field = fieldName; + r.pathScope = getStringValue(resAnn, "pathScope", ""); + r.requestType = getStringValue(resAnn, "requestType", ""); + result.add(r); + } + current = superTypeElement(current); + } + return result; + } + + private void writeResolution(XMLStreamWriter xml, ResolutionInfo r) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("resolution"); + xml.writeCharacters("\n"); + writeElement(xml, "field", r.field, 10); + writeElementIfNotEmpty(xml, "pathScope", r.pathScope, 10); + writeElementIfNotEmpty(xml, "requestType", r.requestType, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // resolution + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // @After / @Afters collection + // ------------------------------------------------------------------------- + + private List<AfterInfo> collectAfters(TypeElement mojoType) { + List<AfterInfo> result = new ArrayList<>(); + // Check for @Afters (container) first + AnnotationMirror aftersAnn = findAnnotation(mojoType, AFTERS_ANN); + if (aftersAnn != null) { + AnnotationValue valueAttr = getAnnotationValue(aftersAnn, "value"); + if (valueAttr != null && valueAttr.getValue() instanceof List<?> list) { + for (Object item : list) { + if (item instanceof AnnotationValue av && av.getValue() instanceof AnnotationMirror afterMirror) { + result.add(extractAfter(afterMirror)); + } + } + } + } + // Also check for a single @After directly on the class + AnnotationMirror afterAnn = findAnnotation(mojoType, AFTER_ANN); + if (afterAnn != null && aftersAnn == null) { + result.add(extractAfter(afterAnn)); + } + return result; + } + + private AfterInfo extractAfter(AnnotationMirror ann) { + AfterInfo a = new AfterInfo(); + a.phase = getStringValue(ann, "phase", ""); + a.type = getEnumValue(ann, "type", "PROJECT"); + a.scope = getStringValue(ann, "scope", ""); + return a; + } + + private void writeAfterLink(XMLStreamWriter xml, AfterInfo a) throws XMLStreamException { + xml.writeCharacters(" "); + xml.writeStartElement("afterLink"); + xml.writeCharacters("\n"); + writeElementIfNotEmpty(xml, "phase", a.phase, 10); + writeElement(xml, "type", a.type, 10); + writeElementIfNotEmpty(xml, "scope", a.scope, 10); + xml.writeCharacters(" "); + xml.writeEndElement(); // afterLink + xml.writeCharacters("\n"); + } + + // ------------------------------------------------------------------------- + // Factory class generation + // ------------------------------------------------------------------------- + + private void generateFactory(TypeElement mojoType) throws IOException { + String groupId = getOption(OPT_GROUP_ID); + String artifactId = getOption(OPT_ARTIFACT_ID); + String version = getOption(OPT_VERSION); + + // Options may not be set yet at factory-generation time (first round before processingOver) + // — use placeholders that get resolved when plugin.xml is written, but the @Named value + // must be stable. If GAV options are missing we skip factory generation; validateOptions() + // will emit the proper error in processingOver(). + if (groupId == null || artifactId == null || version == null) { + return; + } + + AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN); + if (mojoAnn == null) { + return; + } + String goal = getStringValue(mojoAnn, "name", ""); + if (goal.isEmpty()) { + return; + } + + String fqn = mojoType.getQualifiedName().toString(); + String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : ""; + String simpleName = mojoType.getSimpleName().toString(); + String factorySimpleName = simpleName + "$MojoFactory"; + String factoryFqn = pkg.isEmpty() ? factorySimpleName : pkg + "." + factorySimpleName; + String roleHint = groupId + ":" + artifactId + ":" + version + ":" + goal; + + // Collect non-private constructors to replicate in the factory + List<ExecutableElement> ctors = new ArrayList<>(); + for (Element enc : mojoType.getEnclosedElements()) { + if (enc.getKind() == ElementKind.CONSTRUCTOR) { + ExecutableElement ctor = (ExecutableElement) enc; + if (!ctor.getModifiers().contains(Modifier.PRIVATE)) { + ctors.add(ctor); + } + } + } + + JavaFileObject src = processingEnv.getFiler().createSourceFile(factoryFqn, mojoType); + try (PrintWriter pw = new PrintWriter(src.openWriter())) { + writeLicenseHeader(pw); + if (!pkg.isEmpty()) { + pw.println("package " + pkg + ";"); + pw.println(); + } + pw.println("/**"); + pw.println(" * DI factory for the {@code " + goal + "} mojo."); + pw.println(" * Generated by {@link org.apache.maven.plugin.processor.MojoDescriptorProcessor}."); + pw.println(" *"); + pw.println(" * @since 4.1.0"); + pw.println(" */"); + pw.println("@org.apache.maven.api.di.Named(\"" + roleHint + "\")"); + pw.println("public class " + factorySimpleName + " extends " + simpleName + " {"); + pw.println(); + + if (!ctors.isEmpty() && !hasOnlyDefaultCtor(ctors)) { + for (ExecutableElement ctor : ctors) { + if (ctor.getParameters().isEmpty()) { + continue; // default ctor — implicit delegation to super + } + pw.print(" public " + factorySimpleName + "("); + List<? extends VariableElement> params = ctor.getParameters(); + StringBuilder args = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + VariableElement param = params.get(i); + String typeName = param.asType().toString(); + String paramName = param.getSimpleName().toString(); + if (i > 0) { + pw.print(", "); + args.append(", "); + } + pw.print(typeName + " " + paramName); + args.append(paramName); + } + pw.println(") {"); Review Comment: 🐛 **Generated factory constructors omit `throws` clauses.** When replicating mojo constructors, the code writes the parameter list but never calls `ctor.getThrownTypes()` to emit `throws ExceptionType`. If a `@Mojo` class has a constructor that throws a checked exception (e.g. `throws IOException`), the generated factory will fail to compile with `unreported exception`. ```suggestion List<? extends TypeMirror> thrownTypes = ctor.getThrownTypes(); String throwsClause = thrownTypes.isEmpty() ? "" : " throws " + thrownTypes.stream().map(Object::toString) .collect(java.util.stream.Collectors.joining(", ")); pw.println(")" + throwsClause + " {"); ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
