You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@maven.apache.org by "gnodet (via GitHub)" <gi...@apache.org> on 2026/10/20 19:39:26 UTC
[PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
gnodet opened a new pull request, #13658:
URL: https://github.com/apache/maven/pull/13658
## Summary
Implements a JSR 269 annotation processor (`maven-api-plugin-processor`) that generates Maven plugin descriptors (`META-INF/maven/plugin.xml`) and DI factory classes at **compile time** from `@Mojo`-annotated classes, eliminating the need for the external `maven-plugin-plugin` (from maven-plugin-tools) for Maven 4 plugins.
Closes #13644
## Design
Follows the `maven-api-di` / `DiIndexProcessor` pattern:
- **Zero external dependencies** — only `java.compiler` + `java.xml` JDK modules
- Self-processing disabled via `<maven.compiler.proc>none</maven.compiler.proc>`
- Service registration via both `META-INF/services/javax.annotation.processing.Processor` and `module-info.java` `provides` clause
- Returns `false` from `process()` — does not claim `@Mojo`; other processors and IDEs can still see it
- Annotation references are **string-based** — no compile dependency on `maven-api-core` or `maven-api-di`
## What gets generated
### 1. `META-INF/maven/plugin.xml` (v2.0.0 namespace)
Extracts:
- `@Mojo` attributes (goal name, defaultPhase, projectRequired, aggregator, onlineRequired, configurator, dependencyCollection, dependencyResolutionPathScopes)
- `@Execute` (phase, goal, lifecycle)
- `@After` / `@Afters` (repeatable) → `<afterLinks>`
- `@Parameter` on fields/methods — walks full type hierarchy via `TypeElement.getSuperclass()`
- `@Resolution` on fields — same hierarchy walk
- Javadoc via `Elements.getDocComment()` — description (with inline tag stripping), `@since`, `@deprecated`
### 2. Factory source file per mojo
For `com.example.MyMojo` with `@Mojo(name = "goal")`:
```java
@org.apache.maven.api.di.Named("groupId:artifactId:version:goal")
public class MyMojo$MojoFactory extends MyMojo {}
```
The `DiIndexProcessor` (from `maven-api-di`) picks up the `@Named` factory in the next round and adds it to `META-INF/maven/org.apache.maven.api.di.Inject` automatically. This factory is required because `DefaultMavenPluginManager.loadV4Mojo()` looks up mojos via `Key.of(implementationClass, roleHint)` where `roleHint = "groupId:artifactId:version:goal"`.
## Required compiler options (via `-A`)
| Option | Required | Description |
|--------|----------|-------------|
| `maven.plugin.groupId` | yes | Plugin group ID |
| `maven.plugin.artifactId` | yes | Plugin artifact ID |
| `maven.plugin.version` | yes | Plugin version |
| `maven.plugin.goalPrefix` | no | Derived from artifactId if absent |
## Files
- `api/maven-api-plugin-processor/pom.xml` — module POM (parent: `maven-api`, no deps, proc=none)
- `api/maven-api-plugin-processor/src/main/java/module-info.java` — exports package, requires `java.compiler` + `java.xml`, provides `Processor`
- `api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java` — the processor
- `api/maven-api-plugin-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor` — service registration
- `api/pom.xml` — adds `maven-api-plugin-processor` module (after `maven-api-core`)
## Verification
Manually tested against the `mng-12534` IT `TouchMojo` — generated output:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<plugin xmlns="http://maven.apache.org/PLUGIN/2.0.0">
<name>mng12534-plugin</name>
<groupId>org.apache.maven.its.mng12534</groupId>
<artifactId>mng12534-plugin</artifactId>
<version>1.0-SNAPSHOT</version>
<goalPrefix>mng12534</goalPrefix>
<mojos>
<mojo>
<goal>touch</goal>
<phase>compile</phase>
<implementation>org.apache.maven.its.mng12534.TouchMojo</implementation>
<language>java</language>
<description>...</description>
<projectRequired>true</projectRequired>
<onlineRequired>false</onlineRequired>
<aggregator>false</aggregator>
<afterLinks>
<afterLink>
<phase>resources</phase>
<type>PROJECT</type>
</afterLink>
</afterLinks>
</mojo>
</mojos>
</plugin>
```
## Next steps (not in this PR)
- Unit test using `javax.tools.JavaCompiler` API
- Update `mng-12534-after-annotation` IT to use the processor instead of handcrafted `plugin.xml` + factory
- Update issue #13644 (factory classes ARE needed for DI lookup)
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4329675413
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
Review Comment:
⚠️ **`RELEASE_17` is too low for Maven 4.** When plugin authors compile with `-source 21` (or higher), javac emits: *"Supported source version 'RELEASE_17' from annotation processor 'MojoDescriptorProcessor' less than -source '21'"*. This warning will pollute every plugin author's build output. Use `RELEASE_21` at minimum, or override `getSupportedSourceVersion()` to return `SourceVersion.latestSupported()` for forward compatibility.
```suggestion
@SupportedSourceVersion(SourceVersion.RELEASE_21)
```
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
+ } catch (Exception e) {
+ logError("Failed to generate plugin descriptor", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Option validation
+ // -------------------------------------------------------------------------
+
+ private void 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;
+ }
+ }
+ if (!ok) {
+ throw new IllegalStateException("Missing required compiler options");
+ }
Review Comment:
🐛 **Double error reporting.** `validateOptions()` calls `printMessage(ERROR)` for each missing option (lines 174–180), then throws `IllegalStateException` (line 184). The ISE is caught by the broad `catch (Exception e)` at line 158, which calls `logError()` — emitting a **second** `Diagnostic.Kind.ERROR` with a redundant stack trace. Users see both the specific "missing option" messages and a confusing generic failure. Fix: return a boolean from `validateOptions()` and guard generation on its result — no throw needed.
```suggestion
if (!ok) {
return; // errors already reported via printMessage
}
}
```
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
Review Comment:
🐛 **Factories generated during `processingOver()` are invisible to `DiIndexProcessor`.** `generateFactories()` calls `Filer.createSourceFile()` inside the `processingOver()` block. Source files created in the final round do **not** trigger another annotation processing round — the spec (JSR 269 §3.4) does not guarantee additional rounds from the final round. `DiIndexProcessor` (in `maven-api-di`) collects `@Named` classes via `roundEnv.getElementsAnnotatedWith(Named.class)` in each non-final round. Since no additional round fires after `processingOver()`, `DiIndexProcessor` will never see the generated factory classes, and `META-INF/maven/org.apache.maven.api.di.Inject` will be incomplete. The generated factories won't be discoverable by the DI container at runtime.
Fix: generate factory source files in a **non-final round** (e.g. immediately after collecting mojos in round 1), and defer only `writePluginXml()` to `processingOver()`. A flag like `boolean factoriesGenerated` avoids double-generation across rounds.
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
+ } catch (Exception e) {
Review Comment:
💡 **Broad `catch (Exception e)` swallows programming errors.** `IOException` and `XMLStreamException` are the only checked exceptions thrown by `writePluginXml()` and `generateFactories()`. Catching `Exception` also swallows `NullPointerException`, `ClassCastException`, and other bugs, reporting them as "Failed to generate plugin descriptor" — making processor bugs very hard to diagnose. Catch `IOException | XMLStreamException` specifically.
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
+ } catch (Exception e) {
+ logError("Failed to generate plugin descriptor", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Option validation
+ // -------------------------------------------------------------------------
+
+ private void 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;
+ }
+ }
+ if (!ok) {
+ throw new IllegalStateException("Missing required compiler options");
+ }
+ }
+
+ 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 generateFactories() throws IOException {
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+
+ for (Map.Entry<String, TypeElement> entry : mojoElements.entrySet()) {
+ TypeElement mojoType = entry.getValue();
+ AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN);
+ if (mojoAnn == null) {
+ continue;
+ }
+ String goal = getStringValue(mojoAnn, "name", "");
+ if (goal.isEmpty()) {
+ continue;
+ }
+
+ 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)) {
+ // Nothing to generate — implicit default constructor delegates to super
+ } else {
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue; // default ctor — no need to replicate
+ }
+ 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 {
Review Comment:
🧹 **Dead code.** The 3-argument `writeElement(XMLStreamWriter, String, String)` overload (hardcoded indent=4) is never called — all 18 call sites use the explicit-indent 4-argument version. Remove it.
##########
api/maven-api-plugin-processor/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
+ } catch (Exception e) {
+ logError("Failed to generate plugin descriptor", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Option validation
+ // -------------------------------------------------------------------------
+
+ private void 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;
+ }
+ }
+ if (!ok) {
+ throw new IllegalStateException("Missing required compiler options");
+ }
+ }
+
+ 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 generateFactories() throws IOException {
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+
+ for (Map.Entry<String, TypeElement> entry : mojoElements.entrySet()) {
+ TypeElement mojoType = entry.getValue();
+ AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN);
+ if (mojoAnn == null) {
+ continue;
+ }
+ String goal = getStringValue(mojoAnn, "name", "");
+ if (goal.isEmpty()) {
+ continue;
+ }
+
+ 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)) {
+ // Nothing to generate — implicit default constructor delegates to super
+ } else {
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue; // default ctor — no need to replicate
+ }
+ 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:
⚠️ **Javadoc tag-splitting regex is too greedy.** `(?m)^\\s*@` splits on **any** `@` character at the start of a line, including:
- Email addresses in Javadoc (`@example.com`)
- Annotation names in code examples that start a line
- `{@link ...}` or `{@code ...}` that happen to wrap to a new line
This silently truncates mojo descriptions for any Javadoc that happens to have `@` at line start before the actual block tags. Use a stricter lookahead for known block-tag names:
```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,855 @@
+/*
+ * 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.annotation.processing.SupportedSourceVersion;
+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")
+@SupportedSourceVersion(SourceVersion.RELEASE_17)
+@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 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;
+ }
+ mojoElements.put(fqn, typeElement);
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ validateOptions();
+ try {
+ writePluginXml();
+ generateFactories();
+ } catch (Exception e) {
+ logError("Failed to generate plugin descriptor", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Option validation
+ // -------------------------------------------------------------------------
+
+ private void 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;
+ }
+ }
+ if (!ok) {
+ throw new IllegalStateException("Missing required compiler options");
+ }
+ }
+
+ 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 generateFactories() throws IOException {
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+
+ for (Map.Entry<String, TypeElement> entry : mojoElements.entrySet()) {
+ TypeElement mojoType = entry.getValue();
+ AnnotationMirror mojoAnn = findAnnotation(mojoType, MOJO_ANN);
+ if (mojoAnn == null) {
+ continue;
+ }
+ String goal = getStringValue(mojoAnn, "name", "");
+ if (goal.isEmpty()) {
+ continue;
+ }
+
+ 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`. Consider `MyMojo__MojoFactory` or `MyMojoFactory` to avoid false classification.
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4346352250
##########
impl/maven-plugin-mixin/src/site/markdown/usage.md:
##########
@@ -0,0 +1,122 @@
+title: Usage
+author: Maven Team
+date: ${project.version}
+
+<!--
+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.
+-->
+
+# Usage
+
+## Minimal Maven 4 plugin
+
+A complete, functional Maven 4 plugin with one mojo and one `@Parameter`:
+
+```java
+package com.example;
+
+import org.apache.maven.api.plugin.Mojo;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Parameter;
+
+/**
+ * Prints a greeting.
+ *
+ * @since 1.0.0
+ */
+@Mojo(name = "greet", defaultPhase = "compile")
+public class GreetMojo implements Mojo {
+
+ /**
+ * The name to greet.
+ *
+ * @since 1.0.0
+ */
+ @Parameter(property = "greet.name", defaultValue = "World")
+ private String name;
+
+ @Override
+ public void execute() {
+ System.out.println("Hello, " + name + "!");
+ }
+}
+```
+
+POM configuration (model version 4.2.0, using the mixin):
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.2.0" ...>
+
+ <groupId>com.example</groupId>
+ <artifactId>greet-maven-plugin</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <mixins>
+ <mixin>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-plugin-mixin</artifactId>
+ <version>${maven.version}</version>
+ </mixin>
+ </mixins>
+</project>
+```
+
+The mixin configures the compiler plugin automatically — no
+`annotationProcessorPaths` or `-A` options to set up manually.
+
+At compile time, the processor generates:
+
+- `target/classes/META-INF/maven/plugin.xml` — the plugin descriptor
+- `target/generated-sources/annotations/com/example/GreetMojo$MojoFactory.java`
Review Comment:
📝 **Stale doc: `$MojoFactory` removed from code but still in docs.**
Same issue — the generated factory is now `GreetMojoMojoFactory`, not `GreetMojo$MojoFactory`.
```suggestion
- `target/generated-sources/annotations/com/example/GreetMojoMojoFactory.java`
```
##########
impl/maven-plugin-mixin/src/test/java/org/apache/maven/plugin/processor/MojoDescriptorProcessorTest.java:
##########
@@ -0,0 +1,688 @@
+/*
+ * 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.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.FileObject;
+import javax.tools.ForwardingJavaFileManager;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileManager;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringWriter;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Unit tests for {@link MojoDescriptorProcessor} using the JDK in-process compiler.
+ *
+ * <p>Each test compiles a small in-memory source file that uses the Maven plugin
+ * annotations ({@code @Mojo}, {@code @Parameter}, etc.) from maven-api-core on
+ * the classpath, with the processor JAR on the processor path. Generated
+ * resources (plugin.xml) and source files (factory classes) are intercepted by
+ * a {@link CapturingFileManager} and asserted in-memory — no disk I/O.</p>
+ */
+class MojoDescriptorProcessorTest {
+
+ /** Resolved classpath jars needed for compiling test sources. */
+ private static List<File> classpathJars;
+
+ /** The processor JAR (contains the compiled MojoDescriptorProcessor). */
+ private static File processorJar;
+
+ @BeforeAll
+ static void resolveClasspath() {
+ // Processor JAR — built from this module
+ processorJar = resolveFile(
+ "impl/maven-plugin-mixin/target/maven-plugin-mixin-4.1.0-SNAPSHOT.jar",
+ "maven-plugin-mixin/4.1.0-SNAPSHOT/maven-plugin-mixin-4.1.0-SNAPSHOT.jar");
+
+ // Annotation JARs needed on the compilation classpath
+ File apiCore = resolveFile(
+ "api/maven-api-core/target/maven-api-core-4.1.0-SNAPSHOT.jar",
+ "maven-api-core/4.1.0-SNAPSHOT/maven-api-core-4.1.0-SNAPSHOT.jar");
+ File apiAnnotations =
+ resolveFile(null, "maven-api-annotations/4.1.0-SNAPSHOT/maven-api-annotations-4.1.0-SNAPSHOT.jar");
+
+ classpathJars = List.of(apiCore, apiAnnotations);
+ }
+
+ /**
+ * Try to resolve a JAR first from the worktree build output, then from the local Maven repo.
+ */
+ private static File resolveFile(String worktreeRelative, String m2Relative) {
+ String worktreeBase =
+ System.getProperty("processor.worktree", "/opt/data/oss/maven/.worktrees/plugin-processor");
Review Comment:
🧹 **Hardcoded developer machine path as default.**
The default value `/opt/data/oss/maven/.worktrees/plugin-processor` is the developer's local worktree path — it will never exist on any CI machine or other contributor's machine. The fallback to the `.m2` repo means the test doesn't break, but CI will always emit a confusing "file not found in worktree" skip before finding the jar in `.m2`.
Drop the worktree fallback entirely, or set the default to a neutral value:
```suggestion
System.getProperty("processor.worktree", "");
```
Then guard the worktree probe with `!worktreeBase.isEmpty()`.
##########
impl/maven-plugin-mixin/src/site/markdown/index.md:
##########
@@ -0,0 +1,112 @@
+title: Maven Plugin Mixin
+author: Maven Team
+date: ${project.version}
+
+<!--
+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.
+-->
+
+# Maven Plugin Mixin
+
+`maven-plugin-mixin` is a mixin for Maven 4 plugin projects. It bundles a JSR 269
+annotation processor that generates Maven 4 plugin descriptors
+(`META-INF/maven/plugin.xml`) and DI factory classes at compile time — with
+**no dependency on `maven-plugin-plugin`** or `maven-plugin-tools`.
+
+## Overview
+
+When you annotate a class with `@org.apache.maven.api.plugin.annotations.Mojo`,
+the processor fires during `javac` and produces:
+
+1. **`META-INF/maven/plugin.xml`** — the plugin descriptor in v2.0.0 format,
+ describing every mojo's goal name, default phase, parameters, dependency
+ resolution requirements, `@After` ordering constraints, and more.
+
+2. **One factory class per mojo** — e.g. `TouchMojo$MojoFactory`, annotated with
Review Comment:
📝 **Stale doc: `$MojoFactory` removed from code but still in docs.**
The `$` was dropped from factory class names in this commit (now `TouchMojoMojoFactory`, not `TouchMojo$MojoFactory`), but the description still says `TouchMojo$MojoFactory`.
```suggestion
2. **One factory class per mojo** — e.g. `TouchMojoMojoFactory`, annotated with
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,454 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (IOException e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ private void writePluginXml(String[] coords) throws IOException {
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(coords[1])
Review Comment:
⚠️ **`PluginDescriptor.name` falls back to `artifactId` — plugin's human-readable name is lost.**
`coords[1]` is the `artifactId`. The `<name>` element in `plugin.xml` is supposed to be the human-readable plugin name (e.g. `Maven Compiler Plugin`, not `maven-compiler-plugin`). The classic `maven-plugin-plugin` uses the `<name>` from the POM, which is unavailable to an annotation processor.
This means every plugin built with `maven-plugin-mixin` will have `<name>` equal to its `artifactId` in the generated descriptor. This is a known limitation, but it should be either:
1. Documented explicitly in the site docs and the Javadoc of the processor, or
2. Addressed with an optional `-Amaven.plugin.name` option (with `artifactId` as the fallback)
Option 2 is a one-liner: add `OPT_NAME = "maven.plugin.name"` to `@SupportedOptions`, read it in `resolveCoordinates()`, and pass it as `coords[4]`. The mixin's `mixin.xml` can wire it from `${project.name}` automatically.
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4347004017
##########
impl/maven-plugin-mixin/src/site/markdown/usage.md:
##########
@@ -0,0 +1,122 @@
+title: Usage
+author: Maven Team
+date: ${project.version}
+
+<!--
+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.
+-->
+
+# Usage
+
+## Minimal Maven 4 plugin
+
+A complete, functional Maven 4 plugin with one mojo and one `@Parameter`:
+
+```java
+package com.example;
+
+import org.apache.maven.api.plugin.Mojo;
+import org.apache.maven.api.plugin.annotations.Mojo;
Review Comment:
🐛 **Conflicting `Mojo` imports in example code.**
The code snippet imports both:
- `org.apache.maven.api.plugin.Mojo` — the mojo interface
- `org.apache.maven.api.plugin.annotations.Mojo` — the `@Mojo` annotation
Java disallows two imports with the same simple name in the same compilation unit. This example will not compile; the compiler rejects it with a "reference to Mojo is ambiguous" or duplicate import error.
Fix — use a qualified annotation name (matching the TouchMojo IT pattern) or rename one import:
```suggestion
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
```
Then qualify the `implements` clause: `implements org.apache.maven.api.plugin.Mojo`.
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4346732417
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,464 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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,
+ MojoDescriptorProcessor.OPT_NAME
+})
+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";
+ static final String OPT_NAME = "maven.plugin.name";
+
+ private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (IOException e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ String name = getOption(OPT_NAME);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(name)) {
+ name = artifactId;
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
Review Comment:
⚠️ **Duplicate error when coordinates are missing.**
`resolveCoordinates()` is called once per mojo (inside the `process()` loop) and again in the `processingOver()` block. When coordinates are absent, the error is emitted on each call — for a plugin with one mojo, the compiler reports the same message twice; for N mojos, N+1 times.
Two possible fixes:
**Option A** — emit one error per missing option so the user sees exactly what's absent, and avoid the combined message that gets repeated:
```java
if (isBlank(groupId)) error("Missing required option: -A" + OPT_GROUP_ID);
if (isBlank(artifactId)) error("Missing required option: -A" + OPT_ARTIFACT_ID);
if (isBlank(version)) error("Missing required option: -A" + OPT_VERSION);
if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) return null;
```
Individual errors are de-duplicated by `javac` naturally (same error can't be emitted twice on the same element). This also fixes the test comment that claims "for each missing option."
**Option B** — guard the error with a flag: `if (!coordinatesErrorReported) { error(...); coordinatesErrorReported = true; }`
##########
impl/maven-plugin-mixin/src/test/java/org/apache/maven/plugin/processor/MojoDescriptorProcessorTest.java:
##########
@@ -0,0 +1,693 @@
+/*
+ * 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.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.FileObject;
+import javax.tools.ForwardingJavaFileManager;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileManager;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringWriter;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Unit tests for {@link MojoDescriptorProcessor} using the JDK in-process compiler.
+ *
+ * <p>Each test compiles a small in-memory source file that uses the Maven plugin
+ * annotations ({@code @Mojo}, {@code @Parameter}, etc.) from maven-api-core on
+ * the classpath, with the processor JAR on the processor path. Generated
+ * resources (plugin.xml) and source files (factory classes) are intercepted by
+ * a {@link CapturingFileManager} and asserted in-memory — no disk I/O.</p>
+ */
+class MojoDescriptorProcessorTest {
+
+ /** Resolved classpath jars needed for compiling test sources. */
+ private static List<File> classpathJars;
+
+ /** The processor JAR (contains the compiled MojoDescriptorProcessor). */
+ private static File processorJar;
+
+ @BeforeAll
+ static void resolveClasspath() {
+ // Processor JAR — built from this module
+ processorJar = resolveFile(
+ "impl/maven-plugin-mixin/target/maven-plugin-mixin-4.1.0-SNAPSHOT.jar",
+ "maven-plugin-mixin/4.1.0-SNAPSHOT/maven-plugin-mixin-4.1.0-SNAPSHOT.jar");
+
+ // Annotation JARs needed on the compilation classpath
+ File apiCore = resolveFile(
+ "api/maven-api-core/target/maven-api-core-4.1.0-SNAPSHOT.jar",
+ "maven-api-core/4.1.0-SNAPSHOT/maven-api-core-4.1.0-SNAPSHOT.jar");
+ File apiAnnotations =
+ resolveFile(null, "maven-api-annotations/4.1.0-SNAPSHOT/maven-api-annotations-4.1.0-SNAPSHOT.jar");
+
+ classpathJars = List.of(apiCore, apiAnnotations);
+ }
+
+ /**
+ * Try to resolve a JAR first from the Maven multi-module project build output, then from the local Maven repo.
+ *
+ * <p>In a Maven build, {@code maven.multiModuleProjectDirectory} points to the project root, so
+ * {@code worktreeRelative} is resolved against that directory to find freshly-compiled JARs.
+ * The {@code processor.worktree} system property can override the root for IDE / manual runs.</p>
+ */
+ private static File resolveFile(String worktreeRelative, String m2Relative) {
+ // Prefer the Maven multi-module project directory (set by Maven during build)
+ String worktreeBase =
+ System.getProperty("processor.worktree", System.getProperty("maven.multiModuleProjectDirectory", ""));
+ String m2Base = System.getProperty(
+ "processor.m2", System.getProperty("user.home") + "/.m2/repository/org/apache/maven");
+
+ if (worktreeRelative != null && !worktreeBase.isEmpty()) {
+ File f = new File(worktreeBase, worktreeRelative);
+ if (f.isFile()) {
+ return f;
+ }
+ }
+ if (m2Relative != null) {
+ File f = new File(m2Base, m2Relative);
+ if (f.isFile()) {
+ return f;
+ }
+ }
+ fail("Cannot resolve JAR: worktree=" + worktreeRelative + " m2=" + m2Relative);
+ return null; // unreachable
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 1: Minimal @Mojo → plugin.xml + factory
+ // -------------------------------------------------------------------------
+
+ @Test
+ void minimalMojoGeneratesPluginXmlAndFactory() {
+ String source = """
+ package com.example;
+ import org.apache.maven.api.plugin.annotations.Mojo;
+ @Mojo(name = "greet")
+ public class GreetMojo {
+ public void execute() {}
+ }
+ """;
+
+ CompilationResult result = compile(
+ List.of(inMemorySource("com.example.GreetMojo", source)),
+ Map.of(
+ "maven.plugin.groupId", "com.example",
+ "maven.plugin.artifactId", "example-maven-plugin",
+ "maven.plugin.version", "1.0.0"));
+
+ assertTrue(result.success, () -> "Compilation failed:\n" + result.diagnosticsToString());
+
+ // plugin.xml assertions
+ String pluginXml = result.getResource("META-INF/maven/plugin.xml");
+ assertNotNull(pluginXml, "plugin.xml must be generated");
+ assertContains(pluginXml, "<goal>greet</goal>");
+ assertContains(pluginXml, "<implementation>com.example.GreetMojo</implementation>");
+ assertContains(pluginXml, "<groupId>com.example</groupId>");
+ assertContains(pluginXml, "<artifactId>example-maven-plugin</artifactId>");
+ assertContains(pluginXml, "<version>1.0.0</version>");
+ // goalPrefix derived from "example-maven-plugin": strips "-maven-plugin" suffix → "example"
+ assertContains(pluginXml, "<goalPrefix>example</goalPrefix>");
+ assertContains(pluginXml, "<language>java</language>");
+ // default projectRequired = true
+ assertContains(pluginXml, "<projectRequired>true</projectRequired>");
+
+ // Factory class assertion
+ String factorySource = result.getGeneratedSource("com.example.GreetMojoMojoFactory");
+ assertNotNull(factorySource, "Factory source must be generated");
+ assertContains(
+ factorySource, "@org.apache.maven.api.di.Named(\"com.example:example-maven-plugin:1.0.0:greet\")");
+ assertContains(factorySource, "public class GreetMojoMojoFactory extends GreetMojo");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 2: @Mojo with @Parameter fields (readonly → editable inversion)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void mojoWithParametersGeneratesParametersBlock() {
+ String source = """
+ package com.example;
+ import org.apache.maven.api.plugin.annotations.Mojo;
+ import org.apache.maven.api.plugin.annotations.Parameter;
+ @Mojo(name = "compile", defaultPhase = "compile")
+ public class CompileMojo {
+ @Parameter(property = "source", defaultValue = "17", required = true)
+ private String sourceVersion;
+
+ @Parameter(readonly = true, property = "project.basedir")
+ private java.io.File basedir;
+
+ @Parameter(name = "customName", alias = "alt")
+ private int threshold;
+
+ public void execute() {}
+ }
+ """;
+
+ CompilationResult result = compile(
+ List.of(inMemorySource("com.example.CompileMojo", source)),
+ Map.of(
+ "maven.plugin.groupId", "org.apache.maven.plugins",
+ "maven.plugin.artifactId", "maven-compiler-plugin",
+ "maven.plugin.version", "4.0.0"));
+
+ assertTrue(result.success, () -> "Compilation failed:\n" + result.diagnosticsToString());
+
+ String pluginXml = result.getResource("META-INF/maven/plugin.xml");
+ assertNotNull(pluginXml, "plugin.xml must be generated");
+
+ // defaultPhase
+ assertContains(pluginXml, "<phase>compile</phase>");
+
+ // goalPrefix derived from "maven-compiler-plugin" → "compiler"
+ assertContains(pluginXml, "<goalPrefix>compiler</goalPrefix>");
+
+ // <parameters> block
+ assertContains(pluginXml, "<parameters>");
+
+ // sourceVersion parameter
+ assertContains(pluginXml, "<name>sourceVersion</name>");
+ assertContains(pluginXml, "<type>java.lang.String</type>");
+ assertContains(pluginXml, "<required>true</required>");
+ assertContains(pluginXml, "<expression>${source}</expression>");
+ assertContains(pluginXml, "<defaultValue>17</defaultValue>");
+
+ // basedir: readonly=true → editable=false
+ assertContains(pluginXml, "<name>basedir</name>");
+ // The readonly param should generate editable=false
+ assertTrue(
+ pluginXml.contains("<editable>false</editable>"),
+ "readonly=true should produce editable=false in plugin.xml");
+ assertContains(pluginXml, "<expression>${project.basedir}</expression>");
+
+ // customName / alias
+ assertContains(pluginXml, "<name>customName</name>");
+ assertContains(pluginXml, "<alias>alt</alias>");
+
+ // sourceVersion: readonly defaults to false → editable=true
+ // We need to check that the sourceVersion parameter has editable=true
+ // Since there are multiple editable elements, verify non-readonly defaults to editable=true
+ assertTrue(
+ pluginXml.contains("<editable>true</editable>"),
+ "readonly=false (default) should produce editable=true");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 3: @Mojo with @After → afterLinks block
+ // -------------------------------------------------------------------------
+
+ @Test
+ void mojoWithAfterGeneratesAfterLinksBlock() {
+ String source = """
+ package com.example;
+ import org.apache.maven.api.plugin.annotations.Mojo;
+ import org.apache.maven.api.plugin.annotations.After;
+ @Mojo(name = "post-compile")
+ @After(phase = "compile")
+ public class PostCompileMojo {
+ public void execute() {}
+ }
+ """;
+
+ CompilationResult result = compile(
+ List.of(inMemorySource("com.example.PostCompileMojo", source)),
+ Map.of(
+ "maven.plugin.groupId", "com.example",
+ "maven.plugin.artifactId", "example-plugin",
+ "maven.plugin.version", "1.0.0"));
+
+ assertTrue(result.success, () -> "Compilation failed:\n" + result.diagnosticsToString());
+
+ String pluginXml = result.getResource("META-INF/maven/plugin.xml");
+ assertNotNull(pluginXml, "plugin.xml must be generated");
+ assertContains(pluginXml, "<afterLinks>");
+ assertContains(pluginXml, "<afterLink>");
+ assertContains(pluginXml, "<phase>compile</phase>");
+ // default type is PROJECT
+ assertContains(pluginXml, "<type>PROJECT</type>");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 4: @Mojo with @Execute → executePhase/executeGoal
+ // -------------------------------------------------------------------------
+
+ @Test
+ void mojoWithExecuteGeneratesExecuteFields() {
+ String source = """
+ package com.example;
+ import org.apache.maven.api.plugin.annotations.Mojo;
+ import org.apache.maven.api.plugin.annotations.Execute;
+ @Mojo(name = "report")
+ @Execute(phase = "test-compile", lifecycle = "custom")
+ public class ReportMojo {
+ public void execute() {}
+ }
+ """;
+
+ CompilationResult result = compile(
+ List.of(inMemorySource("com.example.ReportMojo", source)),
+ Map.of(
+ "maven.plugin.groupId", "com.example",
+ "maven.plugin.artifactId", "example-plugin",
+ "maven.plugin.version", "2.0.0"));
+
+ assertTrue(result.success, () -> "Compilation failed:\n" + result.diagnosticsToString());
+
+ String pluginXml = result.getResource("META-INF/maven/plugin.xml");
+ assertNotNull(pluginXml, "plugin.xml must be generated");
+ assertContains(pluginXml, "<executePhase>test-compile</executePhase>");
+ assertContains(pluginXml, "<executeLifecycle>custom</executeLifecycle>");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 5: Missing required -A options → ERROR diagnostic
+ // -------------------------------------------------------------------------
+
+ @Test
+ void missingRequiredOptionsEmitsErrorDiagnostic() {
+ String source = """
+ package com.example;
+ import org.apache.maven.api.plugin.annotations.Mojo;
+ @Mojo(name = "noop")
+ public class NoopMojo {
+ public void execute() {}
+ }
+ """;
+
+ // Omit all required -A options
+ CompilationResult result = compile(List.of(inMemorySource("com.example.NoopMojo", source)), Map.of());
+
+ // The processor should emit ERROR diagnostics for each missing option
Review Comment:
🧹 **Misleading comment.** The implementation emits **one** combined error message ("Could not determine plugin coordinates. Supply -A...") that happens to contain all three option names — not three separate diagnostics as the comment implies. The `assertContains` checks pass because each name appears as a substring of the single message.
```suggestion
// The processor emits an error naming all three required options in a single diagnostic
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet commented on PR #13658:
URL: https://github.com/apache/maven/pull/13658#issuecomment-6301137361
Thanks for the thorough review! All three issues have been addressed in the updated push (83fdd26786):
**1. Factories invisible to `DiIndexProcessor` when generated in `processingOver()`** ✅
Refactored `generateFactories()` into `generateFactory(TypeElement)` (singular), called immediately when a mojo is first seen in a normal processing round. Factories are now emitted in round N, so `DiIndexProcessor` sees `@Named` in round N+1. The `processingOver()` block now only writes `plugin.xml`.
**2. `@SupportedSourceVersion(RELEASE_17)` warning on JDK 21+** ✅
Removed the `@SupportedSourceVersion` annotation and the `SupportedSourceVersion` import. Added `@Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); }` instead.
**3. `validateOptions()` double error printing** ✅
Changed `validateOptions()` to return `boolean` instead of throwing `IllegalStateException`. The `processingOver()` block returns early on `false`. The broad `catch (Exception e)` now only wraps the `writePluginXml()` call, which cannot be reached with invalid options.
**Bonus: `deriveGoalPrefix` suffix order bug** (caught by the unit tests added in this push)
The `-plugin` suffix was checked before `-maven-plugin`, so `"example-maven-plugin"` was incorrectly yielding `"example-maven"`. Fixed by checking the longer suffix first.
**Also added in this push:**
- 13 unit tests (`MojoDescriptorProcessorTest`) using `javax.tools.JavaCompiler` in-process — all green
- IT `gh-13644-plugin-processor` testing end-to-end plugin build + execution with the processor
- Updated `mng-12534` IT plugin to use the processor instead of `maven-plugin-plugin`
- Site documentation (`index.md`, `usage.md`)
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet commented on PR #13658:
URL: https://github.com/apache/maven/pull/13658#issuecomment-6301905312
Updated (02114950a0): `maven-api-plugin-processor` now reads `groupId`, `artifactId`, `version` and `goalPrefix` directly from the project's `pom.xml` at compile time (via `Filer.getResource(SOURCE_PATH, "", "pom.xml")`). No `-A` options required.
The reading logic:
1. Tries `SOURCE_PATH/pom.xml` first, then `CLASS_OUTPUT/pom.xml` as fallback
2. Parses groupId/artifactId/version from `/project/*` and goalPrefix from the `maven-plugin-plugin` configuration block
3. Falls back gracefully when the filer doesn't support resource reading (UnsupportedOperationException wrapped as RuntimeException by javac's ClientCodeWrapper)
4. `-A` options still work as explicit overrides
The `gh-13644` IT POM now has zero `<compilerArgs>` — just `annotationProcessorPaths`. 16/16 unit tests green (added 3 new `parsePom` tests).
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4341267460
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
Review Comment:
⚠️ **`catch (Exception e)` too broad — still open from prior review.**
`writePluginXml()` declares `throws IOException, XMLStreamException`. Catching `Exception` here silently swallows any unchecked `RuntimeException` (e.g. `NullPointerException`, `IllegalStateException`) from the XML generation path, converting it into a compiler diagnostic (`logError`) instead of propagating it. This makes debugging impossible — a programming error looks like a generation failure.
```suggestion
} catch (IOException | XMLStreamException e) {
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
Review Comment:
🔒 **Missing `IS_SUPPORTING_EXTERNAL_ENTITIES=false` — still open from prior review.**
The OWASP XXE hardening triple requires all three properties. Setting only `SUPPORT_DTD=false` and `IS_REPLACING_ENTITY_REFERENCES=false` leaves the parser able to process external entity declarations on implementations that ignore `SUPPORT_DTD`. The third property is the defense-in-depth guard.
```suggestion
factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
factory.setProperty(javax.xml.stream.XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 — still open from prior review.**
`MyMojo$MojoFactory` uses `$`, which the JLS (§3.8) reserves for compiler-generated names (inner classes, lambdas, etc.). Tools such as ProGuard, Byte Buddy, and some classloaders treat `$` as a nested-class separator and may misclassify or strip `MyMojo$MojoFactory`. The canonical suffix for generated peer classes is `_`, giving `MyMojo_MojoFactory`, which is unambiguous.
```suggestion
String factorySimpleName = simpleName + "_MojoFactory";
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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:
🐛 **Factory constructor missing `throws` clause — still open from prior review.**
If a mojo declares a checked exception in its constructor (e.g. `MyMojo() throws IOException`), the generated factory constructor does not replicate the `throws` clause — producing code that fails to compile. `ctor.getThrownTypes()` must be queried and appended before `pw.println(") {")` on line 750.
Fix: after the parameter loop closes (line 749), insert:
```java
List<? extends TypeMirror> thrown = ctor.getThrownTypes();
if (!thrown.isEmpty()) {
pw.print(" throws ");
pw.print(thrown.stream()
.map(Object::toString)
.collect(java.util.stream.Collectors.joining(", ")));
}
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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: 3-arg `writeElement` overload — still open from prior review.**
This method is never called. Every call site in the file explicitly passes an indent argument (2, 6, or 10) to the 4-arg overload. The 3-arg version with its hardcoded `indent=4` is unreachable dead code. Remove it.
```suggestion
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 open from prior review.**
`(?m)^\s*@` splits on **any** `@` at the start of a line — including `@author`, `@see`, `@link`, `@code`, email addresses in Javadoc text, and even annotation lines in code examples. Add a lookahead that only splits on known Javadoc block tags:
```suggestion
String[] parts = raw.split("(?m)^\\s*@(?=param|return|throws|exception|since|deprecated|see|author|version|serial|serialField|serialData|hidden|apiNote|implSpec|implNote|spec)");
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4341493344
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,903 @@
+/*
+ * 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>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
Review Comment:
⚠️ **`catch (Exception e)` too broad — still open from prior review.**
`writePluginXml()` declares `throws IOException, XMLStreamException`. Catching raw `Exception` means `NullPointerException`, `IllegalStateException`, or any other programming error in this code path is silently converted into a compiler NOTE and swallowed — the build continues with a corrupt or missing `plugin.xml`, causing mysterious downstream failures.
```suggestion
} catch (IOException | XMLStreamException e) {
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,903 @@
+/*
+ * 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>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from {@code -A} compiler options.
+ * The result is cached after the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if required options are missing
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 open from prior review.**
`(?m)^\\s*@` splits on **any** `@` at the start of a line — email addresses (`@gnodet`), `@Override` inside code examples, or `@` in parameter expressions will all be mis-split. Description text gets truncated silently at the first false-positive `@`.
```suggestion
String[] parts = raw.split("(?m)^\\s*@(?=param|return|throws|exception|see|since|deprecated|version|author)");
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,903 @@
+/*
+ * 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>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from {@code -A} compiler options.
+ * The result is cached after the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if required options are missing
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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: 3-arg `writeElement` overload — still open from prior review.**
All call sites in the file pass an explicit `indent` argument (4-arg form). This 3-arg overload (which just hardcodes `indent=4`) is never invoked directly.
```suggestion
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,903 @@
+/*
+ * 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>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from {@code -A} compiler options.
+ * The result is cached after the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if required options are missing
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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:
💡 **`$MojoFactory` naming — still open from prior review.**
JLS §3.8 reserves `$` for compiler-generated identifiers. Using it in an annotation-processor-generated top-level source file makes `com.example.MyMojo$MojoFactory` binary-name-indistinguishable from a nested class `MyMojo.MojoFactory`. IDEs (IntelliJ/Eclipse) show it as an inner class, bytecode tools (ProGuard, JaCoCo, SpotBugs) treat it as one, and it can collide with a user-defined inner class named `MojoFactory`.
```suggestion
String factorySimpleName = simpleName + "MojoFactory";
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,903 @@
+/*
+ * 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>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from {@code -A} compiler options.
+ * The result is cached after the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if required options are missing
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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:
🐛 **Factory constructor missing `throws` clause — still open from prior review.**
`ctor.getThrownTypes()` is never consulted. Any `@Mojo` class with a constructor that declares checked exceptions will produce a factory that fails to compile:
```java
// @Mojo class:
public class MyMojo {
public MyMojo(Session s) throws SomeCheckedException { ... }
}
// Generated factory (broken):
public class MyMojoMojoFactory extends MyMojo {
public MyMojoMojoFactory(Session s) { // ERROR: unhandled SomeCheckedException
super(s);
}
}
```
Fix: after collecting parameters, query `ctor.getThrownTypes()` and emit a `throws` clause:
```suggestion
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);
}
List<? extends TypeMirror> thrownTypes = ctor.getThrownTypes();
if (!thrownTypes.isEmpty()) {
pw.print(") throws ");
for (int i = 0; i < thrownTypes.size(); i++) {
if (i > 0) pw.print(", ");
pw.print(thrownTypes.get(i).toString());
}
pw.println(" {");
} else {
pw.println(") {");
}
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4342482735
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,447 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
Review Comment:
⚠️ **`catch (Exception e)` is too broad — still open from prior reviews.**
`writePluginXml()` declares `throws IOException` only. The `XMLStreamException` it can throw is already caught inside and re-wrapped as `IOException`. Catching `Exception` here silently converts `RuntimeException`s (e.g. `NullPointerException` from a corrupt builder state) into compiler diagnostics instead of letting them propagate as real errors.
```suggestion
} catch (IOException e) {
```
##########
impl/maven-plugin-mixin/pom.xml:
##########
@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-impl-modules</artifactId>
+ <version>4.1.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>maven-plugin-mixin</artifactId>
+ <name>Maven Plugin Mixin</name>
+ <description>Mixin and annotation processor for Maven 4 plugin projects. The mixin provides
+ zero-config plugin descriptor generation at compile time from @Mojo-annotated classes.</description>
+
+ <properties>
+ <javaModuleName>org.apache.maven.plugin.processor</javaModuleName>
+ <maven.compiler.proc>none</maven.compiler.proc>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-api-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-api-plugin</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-support</artifactId>
+ <exclusions>
+ <exclusion>
+ <groupId>com.fasterxml.woodstox</groupId>
+ <artifactId>woodstox-core</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.codehaus.woodstox</groupId>
+ <artifactId>stax2-api</artifactId>
+ </exclusion>
+ </exclusions>
Review Comment:
🐛 **Woodstox exclusion breaks annotation processing for all consumers.**
`PluginDescriptorStaxWriter` (Modello-generated) hard-codes:
```java
XMLOutputFactory factory = new com.ctc.wstx.stax.WstxOutputFactory();
```
This is not SPI-based — it instantiates `WstxOutputFactory` directly. Excluding `woodstox-core` from the `maven-support` dependency means Woodstox is not on the annotation-processor classpath. When `writePluginXml()` runs, any plugin project using this mixin will fail with:
```
java.lang.NoClassDefFoundError: com/ctc/wstx/stax/WstxOutputFactory
```
The unit test (`MojoDescriptorProcessorTest.minimalMojoGeneratesPluginXmlAndFactory`) also exercises `writePluginXml()` and will fail in isolation (it passes in the full reactor build only because other Maven modules bring Woodstox onto the JVM).
**Fix:** remove the `woodstox-core` and `stax2-api` exclusions. Woodstox is needed at annotation-processing time, not at plugin runtime, so the footprint concern is moot.
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,447 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ private void writePluginXml(String[] coords) throws IOException {
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(coords[1])
+ .groupId(coords[0])
+ .artifactId(coords[1])
+ .version(coords[2])
+ .goalPrefix(coords[3])
+ .mojos(mojos)
+ .build();
+ try (Writer w = processingEnv
+ .getFiler()
+ .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml")
+ .openWriter()) {
+ PluginDescriptorStaxWriter writer = new PluginDescriptorStaxWriter();
+ writer.setNamespace("http://maven.apache.org/PLUGIN/2.0.0");
+ writer.write(w, descriptor);
+ } catch (XMLStreamException e) {
+ throw new IOException("Failed to write plugin descriptor", e);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ if (mojoAnn != null) {
+ error("@Mojo.name() must not be empty", mojoType);
+ }
+ return null;
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+ MojoDescriptor.Builder b = MojoDescriptor.newBuilder(true)
+ .goal(mojoAnn.name())
+ .implementation(mojoType.getQualifiedName().toString())
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+ set(b::phase, mojoAnn.defaultPhase());
+ set(b::configurator, mojoAnn.configurator());
+ set(b::dependencyResolution, mojoAnn.dependencyResolutionPathScopes());
+ if (mojoAnn.dependencyCollection()) {
+ b.dependencyCollection("true");
+ }
+ set(b::description, doc.description);
+ set(b::since, doc.since);
+ set(b::deprecated, doc.deprecated);
+ Execute exec = mojoType.getAnnotation(Execute.class);
+ if (exec != null) {
+ set(b::executePhase, exec.phase());
+ set(b::executeGoal, exec.goal());
+ set(b::executeLifecycle, exec.lifecycle());
+ }
+ b.parameters(collectParameters(mojoType));
+ b.resolutions(collectResolutions(mojoType));
+ b.afterLinks(collectAfterLinks(mojoType));
+ return b.build();
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) {
+ continue;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter ann =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (ann == null) {
+ continue;
+ }
+ String name = ann.name().isEmpty() ? enclosed.getSimpleName().toString() : ann.name();
+ if (!seen.add(name)) {
+ continue;
+ }
+ String type = enclosed.getKind() == ElementKind.FIELD
+ ? enclosed.asType().toString()
+ : (((ExecutableElement) enclosed).getParameters().isEmpty()
+ ? ((ExecutableElement) enclosed).getReturnType().toString()
+ : ((ExecutableElement) enclosed)
+ .getParameters()
+ .get(0)
+ .asType()
+ .toString());
+ String expression = ann.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(name)
+ .type(type)
+ .required(ann.required())
+ .editable(!ann.readonly());
+ set(pb::alias, ann.alias());
+ set(pb::expression, expression);
+ set(pb::defaultValue, ann.defaultValue());
+ set(pb::description, doc.description);
+ set(pb::since, doc.since);
+ set(pb::deprecated, doc.deprecated);
+ result.add(pb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD) {
+ continue;
+ }
+ Resolution ann = enclosed.getAnnotation(Resolution.class);
+ if (ann == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (!seen.add(fieldName)) {
+ continue;
+ }
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ set(rb::pathScope, ann.pathScope());
+ set(rb::requestType, ann.requestType());
+ result.add(rb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>(afters.length);
+ for (After a : afters) {
+ AfterLink.Builder ab =
+ AfterLink.newBuilder(true).phase(a.phase()).type(a.type().name());
+ set(ab::scope, a.scope());
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ return;
+ }
+ String fqn = mojoType.getQualifiedName().toString();
+ String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : "";
+ String simpleName = mojoType.getSimpleName().toString();
+ String factoryName = simpleName + "$MojoFactory";
Review Comment:
💡 **`$` in generated class names — still open from prior reviews.**
`MyMojo$MojoFactory` uses `$`, which the JLS §3.8 reserves for compiler-generated identifiers. While javac will compile it, tools (debuggers, serialization, class loaders using `Class.forName`) may mis-parse the `$` as an inner-class separator. Use `MojoFactory` as a suffix instead:
```suggestion
String factoryName = simpleName + "MojoFactory";
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,447 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ private void writePluginXml(String[] coords) throws IOException {
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(coords[1])
+ .groupId(coords[0])
+ .artifactId(coords[1])
+ .version(coords[2])
+ .goalPrefix(coords[3])
+ .mojos(mojos)
+ .build();
+ try (Writer w = processingEnv
+ .getFiler()
+ .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml")
+ .openWriter()) {
+ PluginDescriptorStaxWriter writer = new PluginDescriptorStaxWriter();
+ writer.setNamespace("http://maven.apache.org/PLUGIN/2.0.0");
+ writer.write(w, descriptor);
+ } catch (XMLStreamException e) {
+ throw new IOException("Failed to write plugin descriptor", e);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ if (mojoAnn != null) {
+ error("@Mojo.name() must not be empty", mojoType);
+ }
+ return null;
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+ MojoDescriptor.Builder b = MojoDescriptor.newBuilder(true)
+ .goal(mojoAnn.name())
+ .implementation(mojoType.getQualifiedName().toString())
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+ set(b::phase, mojoAnn.defaultPhase());
+ set(b::configurator, mojoAnn.configurator());
+ set(b::dependencyResolution, mojoAnn.dependencyResolutionPathScopes());
+ if (mojoAnn.dependencyCollection()) {
+ b.dependencyCollection("true");
+ }
+ set(b::description, doc.description);
+ set(b::since, doc.since);
+ set(b::deprecated, doc.deprecated);
+ Execute exec = mojoType.getAnnotation(Execute.class);
+ if (exec != null) {
+ set(b::executePhase, exec.phase());
+ set(b::executeGoal, exec.goal());
+ set(b::executeLifecycle, exec.lifecycle());
+ }
+ b.parameters(collectParameters(mojoType));
+ b.resolutions(collectResolutions(mojoType));
+ b.afterLinks(collectAfterLinks(mojoType));
+ return b.build();
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) {
+ continue;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter ann =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (ann == null) {
+ continue;
+ }
+ String name = ann.name().isEmpty() ? enclosed.getSimpleName().toString() : ann.name();
+ if (!seen.add(name)) {
+ continue;
+ }
+ String type = enclosed.getKind() == ElementKind.FIELD
+ ? enclosed.asType().toString()
+ : (((ExecutableElement) enclosed).getParameters().isEmpty()
+ ? ((ExecutableElement) enclosed).getReturnType().toString()
+ : ((ExecutableElement) enclosed)
+ .getParameters()
+ .get(0)
+ .asType()
+ .toString());
+ String expression = ann.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(name)
+ .type(type)
+ .required(ann.required())
+ .editable(!ann.readonly());
+ set(pb::alias, ann.alias());
+ set(pb::expression, expression);
+ set(pb::defaultValue, ann.defaultValue());
+ set(pb::description, doc.description);
+ set(pb::since, doc.since);
+ set(pb::deprecated, doc.deprecated);
+ result.add(pb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD) {
+ continue;
+ }
+ Resolution ann = enclosed.getAnnotation(Resolution.class);
+ if (ann == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (!seen.add(fieldName)) {
+ continue;
+ }
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ set(rb::pathScope, ann.pathScope());
+ set(rb::requestType, ann.requestType());
+ result.add(rb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>(afters.length);
+ for (After a : afters) {
+ AfterLink.Builder ab =
+ AfterLink.newBuilder(true).phase(a.phase()).type(a.type().name());
+ set(ab::scope, a.scope());
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ return;
+ }
+ String fqn = mojoType.getQualifiedName().toString();
+ String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : "";
+ String simpleName = mojoType.getSimpleName().toString();
+ String factoryName = simpleName + "$MojoFactory";
+ String factoryFqn = pkg.isEmpty() ? factoryName : pkg + "." + factoryName;
+ String roleHint = coords[0] + ":" + coords[1] + ":" + coords[2] + ":" + mojoAnn.name();
+
+ List<ExecutableElement> ctors = mojoType.getEnclosedElements().stream()
+ .filter(e -> e.getKind() == ElementKind.CONSTRUCTOR)
+ .map(ExecutableElement.class::cast)
+ .filter(c -> !c.getModifiers().contains(Modifier.PRIVATE))
+ .toList();
+
+ try (PrintWriter pw = new PrintWriter(
+ processingEnv.getFiler().createSourceFile(factoryFqn, mojoType).openWriter())) {
+ if (!pkg.isEmpty()) {
+ pw.println("package " + pkg + ";");
+ pw.println();
+ }
+ pw.println("@org.apache.maven.api.di.Named(\"" + roleHint + "\")");
+ pw.println("public class " + factoryName + " extends " + simpleName + " {");
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue;
+ }
+ pw.print(" public " + factoryName + "(");
+ StringBuilder args = new StringBuilder();
+ for (int i = 0; i < ctor.getParameters().size(); i++) {
+ VariableElement p = ctor.getParameters().get(i);
+ if (i > 0) {
+ pw.print(", ");
+ args.append(", ");
+ }
+ pw.print(p.asType() + " " + p.getSimpleName());
+ args.append(p.getSimpleName());
+ }
+ pw.println(") { super(" + args + "); }");
Review Comment:
🐛 **Factory constructor missing `throws` clause — still open from prior reviews.**
`ctor.getThrownTypes()` is never consulted. If a mojo constructor declares a checked exception (e.g. `public MyMojo() throws IOException`), the generated factory constructor `public MyMojoMojoFactory() { super(); }` will fail to compile with:
```
error: unreported exception IOException; must be caught or declared to be thrown
```
Fix — after writing the parameter list, emit the `throws` clause if the original constructor has checked exceptions:
```suggestion
pw.print(") ");
List<? extends TypeMirror> thrown = ctor.getThrownTypes();
if (!thrown.isEmpty()) {
pw.print("throws ");
for (int j = 0; j < thrown.size(); j++) {
if (j > 0) pw.print(", ");
pw.print(thrown.get(j));
}
pw.print(" ");
}
pw.println("{ super(" + args + "); }");
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,447 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ private void writePluginXml(String[] coords) throws IOException {
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(coords[1])
+ .groupId(coords[0])
+ .artifactId(coords[1])
+ .version(coords[2])
+ .goalPrefix(coords[3])
+ .mojos(mojos)
+ .build();
+ try (Writer w = processingEnv
+ .getFiler()
+ .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml")
+ .openWriter()) {
+ PluginDescriptorStaxWriter writer = new PluginDescriptorStaxWriter();
+ writer.setNamespace("http://maven.apache.org/PLUGIN/2.0.0");
+ writer.write(w, descriptor);
+ } catch (XMLStreamException e) {
+ throw new IOException("Failed to write plugin descriptor", e);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ if (mojoAnn != null) {
+ error("@Mojo.name() must not be empty", mojoType);
+ }
+ return null;
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+ MojoDescriptor.Builder b = MojoDescriptor.newBuilder(true)
+ .goal(mojoAnn.name())
+ .implementation(mojoType.getQualifiedName().toString())
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+ set(b::phase, mojoAnn.defaultPhase());
+ set(b::configurator, mojoAnn.configurator());
+ set(b::dependencyResolution, mojoAnn.dependencyResolutionPathScopes());
+ if (mojoAnn.dependencyCollection()) {
+ b.dependencyCollection("true");
+ }
+ set(b::description, doc.description);
+ set(b::since, doc.since);
+ set(b::deprecated, doc.deprecated);
+ Execute exec = mojoType.getAnnotation(Execute.class);
+ if (exec != null) {
+ set(b::executePhase, exec.phase());
+ set(b::executeGoal, exec.goal());
+ set(b::executeLifecycle, exec.lifecycle());
+ }
+ b.parameters(collectParameters(mojoType));
+ b.resolutions(collectResolutions(mojoType));
+ b.afterLinks(collectAfterLinks(mojoType));
+ return b.build();
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD && enclosed.getKind() != ElementKind.METHOD) {
+ continue;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter ann =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (ann == null) {
+ continue;
+ }
+ String name = ann.name().isEmpty() ? enclosed.getSimpleName().toString() : ann.name();
+ if (!seen.add(name)) {
+ continue;
+ }
+ String type = enclosed.getKind() == ElementKind.FIELD
+ ? enclosed.asType().toString()
+ : (((ExecutableElement) enclosed).getParameters().isEmpty()
+ ? ((ExecutableElement) enclosed).getReturnType().toString()
+ : ((ExecutableElement) enclosed)
+ .getParameters()
+ .get(0)
+ .asType()
+ .toString());
+ String expression = ann.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(name)
+ .type(type)
+ .required(ann.required())
+ .editable(!ann.readonly());
+ set(pb::alias, ann.alias());
+ set(pb::expression, expression);
+ set(pb::defaultValue, ann.defaultValue());
+ set(pb::description, doc.description);
+ set(pb::since, doc.since);
+ set(pb::deprecated, doc.deprecated);
+ result.add(pb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> result = new ArrayList<>();
+ Set<String> seen = new LinkedHashSet<>();
+ for (TypeElement cur = mojoType; cur != null && !isObject(cur); cur = superType(cur)) {
+ for (Element enclosed : cur.getEnclosedElements()) {
+ if (enclosed.getKind() != ElementKind.FIELD) {
+ continue;
+ }
+ Resolution ann = enclosed.getAnnotation(Resolution.class);
+ if (ann == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (!seen.add(fieldName)) {
+ continue;
+ }
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ set(rb::pathScope, ann.pathScope());
+ set(rb::requestType, ann.requestType());
+ result.add(rb.build());
+ }
+ }
+ return result;
+ }
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>(afters.length);
+ for (After a : afters) {
+ AfterLink.Builder ab =
+ AfterLink.newBuilder(true).phase(a.phase()).type(a.type().name());
+ set(ab::scope, a.scope());
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().isEmpty()) {
+ return;
+ }
+ String fqn = mojoType.getQualifiedName().toString();
+ String pkg = fqn.contains(".") ? fqn.substring(0, fqn.lastIndexOf('.')) : "";
+ String simpleName = mojoType.getSimpleName().toString();
+ String factoryName = simpleName + "$MojoFactory";
+ String factoryFqn = pkg.isEmpty() ? factoryName : pkg + "." + factoryName;
+ String roleHint = coords[0] + ":" + coords[1] + ":" + coords[2] + ":" + mojoAnn.name();
+
+ List<ExecutableElement> ctors = mojoType.getEnclosedElements().stream()
+ .filter(e -> e.getKind() == ElementKind.CONSTRUCTOR)
+ .map(ExecutableElement.class::cast)
+ .filter(c -> !c.getModifiers().contains(Modifier.PRIVATE))
+ .toList();
+
+ try (PrintWriter pw = new PrintWriter(
+ processingEnv.getFiler().createSourceFile(factoryFqn, mojoType).openWriter())) {
+ if (!pkg.isEmpty()) {
+ pw.println("package " + pkg + ";");
+ pw.println();
+ }
+ pw.println("@org.apache.maven.api.di.Named(\"" + roleHint + "\")");
+ pw.println("public class " + factoryName + " extends " + simpleName + " {");
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue;
+ }
+ pw.print(" public " + factoryName + "(");
+ StringBuilder args = new StringBuilder();
+ for (int i = 0; i < ctor.getParameters().size(); i++) {
+ VariableElement p = ctor.getParameters().get(i);
+ if (i > 0) {
+ pw.print(", ");
+ args.append(", ");
+ }
+ pw.print(p.asType() + " " + p.getSimpleName());
+ args.append(p.getSimpleName());
+ }
+ pw.println(") { super(" + args + "); }");
+ }
+ pw.println("}");
+ }
+ generatedFactoryNames.add(factoryFqn);
+ note("Generated factory " + factoryFqn);
+ }
+
+ static JavadocInfo parseJavadoc(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return new JavadocInfo(null, null, null);
+ }
+ String[] parts = raw.split("(?m)^\\s*@");
Review Comment:
⚠️ **Greedy Javadoc `@` regex — still open from prior reviews.**
`(?m)^\s*@` splits on **any** `@` at the start of a line — including email addresses (`@author foo@example.com`) and annotation references inside the text body. A Javadoc like:
```java
/**
* Contact author@example.com for details.
* @since 1.0
*/
```
would produce a spurious split at `author@example.com` if that line happens to start with whitespace + `@`.
Use the standard Javadoc tag pattern that requires the `@` to begin a known tag word:
```suggestion
String[] parts = raw.split("(?m)^\s*@(?=param|return|throws|exception|since|deprecated|see|author|version|serial)");
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4346478798
##########
impl/maven-plugin-mixin/src/site/markdown/index.md:
##########
@@ -0,0 +1,112 @@
+title: Maven Plugin Mixin
+author: Maven Team
+date: ${project.version}
+
+<!--
+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.
+-->
+
+# Maven Plugin Mixin
+
+`maven-plugin-mixin` is a mixin for Maven 4 plugin projects. It bundles a JSR 269
+annotation processor that generates Maven 4 plugin descriptors
+(`META-INF/maven/plugin.xml`) and DI factory classes at compile time — with
+**no dependency on `maven-plugin-plugin`** or `maven-plugin-tools`.
+
+## Overview
+
+When you annotate a class with `@org.apache.maven.api.plugin.annotations.Mojo`,
+the processor fires during `javac` and produces:
+
+1. **`META-INF/maven/plugin.xml`** — the plugin descriptor in v2.0.0 format,
+ describing every mojo's goal name, default phase, parameters, dependency
+ resolution requirements, `@After` ordering constraints, and more.
+
+2. **One factory class per mojo** — e.g. `TouchMojo$MojoFactory`, annotated with
Review Comment:
⚠️ **Stale class name**: the processor generates a top-level class `TouchMojoMojoFactory` (see `factoryName = simpleName + "MojoFactory"` in `MojoDescriptorProcessor.java`), but this doc uses `TouchMojo$MojoFactory` — the `$` notation implies a nested class, which is incorrect.
##########
impl/maven-plugin-mixin/src/test/java/org/apache/maven/plugin/processor/MojoDescriptorProcessorTest.java:
##########
@@ -0,0 +1,688 @@
+/*
+ * 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.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.FileObject;
+import javax.tools.ForwardingJavaFileManager;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileManager;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringWriter;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Unit tests for {@link MojoDescriptorProcessor} using the JDK in-process compiler.
+ *
+ * <p>Each test compiles a small in-memory source file that uses the Maven plugin
+ * annotations ({@code @Mojo}, {@code @Parameter}, etc.) from maven-api-core on
+ * the classpath, with the processor JAR on the processor path. Generated
+ * resources (plugin.xml) and source files (factory classes) are intercepted by
+ * a {@link CapturingFileManager} and asserted in-memory — no disk I/O.</p>
+ */
+class MojoDescriptorProcessorTest {
+
+ /** Resolved classpath jars needed for compiling test sources. */
+ private static List<File> classpathJars;
+
+ /** The processor JAR (contains the compiled MojoDescriptorProcessor). */
+ private static File processorJar;
+
+ @BeforeAll
+ static void resolveClasspath() {
+ // Processor JAR — built from this module
+ processorJar = resolveFile(
+ "impl/maven-plugin-mixin/target/maven-plugin-mixin-4.1.0-SNAPSHOT.jar",
+ "maven-plugin-mixin/4.1.0-SNAPSHOT/maven-plugin-mixin-4.1.0-SNAPSHOT.jar");
+
+ // Annotation JARs needed on the compilation classpath
+ File apiCore = resolveFile(
+ "api/maven-api-core/target/maven-api-core-4.1.0-SNAPSHOT.jar",
+ "maven-api-core/4.1.0-SNAPSHOT/maven-api-core-4.1.0-SNAPSHOT.jar");
+ File apiAnnotations =
+ resolveFile(null, "maven-api-annotations/4.1.0-SNAPSHOT/maven-api-annotations-4.1.0-SNAPSHOT.jar");
+
+ classpathJars = List.of(apiCore, apiAnnotations);
+ }
+
+ /**
+ * Try to resolve a JAR first from the worktree build output, then from the local Maven repo.
+ */
+ private static File resolveFile(String worktreeRelative, String m2Relative) {
+ String worktreeBase =
+ System.getProperty("processor.worktree", "/opt/data/oss/maven/.worktrees/plugin-processor");
Review Comment:
🐛 **Hardcoded developer path as default**: `"/opt/data/oss/maven/.worktrees/plugin-processor"` is a machine-specific path. Any other contributor running the tests will hit this fallback and fail. The default should be `null` (or omitted), forcing the test to only use the Maven local repository path (`processor.m2`) which is already portable via `user.home`.
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,458 @@
+/*
+ * 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.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.StandardLocation;
+import javax.xml.stream.XMLStreamException;
+
+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;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.plugin.descriptor.io.PluginDescriptorStaxWriter;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>Plugin coordinates are read from compiler {@code -A} options, typically provided by the
+ * {@code maven-plugin-mixin}.
+ *
+ * @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 final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ if (typeElement.getKind() == ElementKind.INTERFACE) {
+ error("@Mojo must not be placed on an interface", element);
+ continue;
+ }
+ if (typeElement.getModifiers().contains(Modifier.ABSTRACT)) {
+ error("@Mojo must not be placed on an abstract class", element);
+ continue;
+ }
+ if (!mojoElements.containsKey(fqn)) {
+ mojoElements.put(fqn, typeElement);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ error("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+ if (roundEnv.processingOver() && !mojoElements.isEmpty()) {
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ writePluginXml(coords);
+ } catch (IOException e) {
+ error("Failed to generate plugin.xml", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (isBlank(goalPrefix)) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+ if (isBlank(groupId) || isBlank(artifactId) || isBlank(version)) {
+ error("Could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ private void writePluginXml(String[] coords) throws IOException {
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(coords[1])
Review Comment:
🐛 **`PluginDescriptor.name` set to artifactId**: `.name(coords[1])` and `.artifactId(coords[1])` both use the same value. `name` should be a human-readable label (e.g. the Maven artifact name or goal prefix), not a duplicate of the artifactId. Consider reading the name from a plugin-level annotation or at minimum using the goal prefix instead.
##########
impl/maven-plugin-mixin/src/site/markdown/usage.md:
##########
@@ -0,0 +1,122 @@
+title: Usage
+author: Maven Team
+date: ${project.version}
+
+<!--
+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.
+-->
+
+# Usage
+
+## Minimal Maven 4 plugin
+
+A complete, functional Maven 4 plugin with one mojo and one `@Parameter`:
+
+```java
+package com.example;
+
+import org.apache.maven.api.plugin.Mojo;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Parameter;
+
+/**
+ * Prints a greeting.
+ *
+ * @since 1.0.0
+ */
+@Mojo(name = "greet", defaultPhase = "compile")
+public class GreetMojo implements Mojo {
+
+ /**
+ * The name to greet.
+ *
+ * @since 1.0.0
+ */
+ @Parameter(property = "greet.name", defaultValue = "World")
+ private String name;
+
+ @Override
+ public void execute() {
+ System.out.println("Hello, " + name + "!");
+ }
+}
+```
+
+POM configuration (model version 4.2.0, using the mixin):
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.2.0" ...>
+
+ <groupId>com.example</groupId>
+ <artifactId>greet-maven-plugin</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <mixins>
+ <mixin>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-plugin-mixin</artifactId>
+ <version>${maven.version}</version>
+ </mixin>
+ </mixins>
+</project>
+```
+
+The mixin configures the compiler plugin automatically — no
+`annotationProcessorPaths` or `-A` options to set up manually.
+
+At compile time, the processor generates:
+
+- `target/classes/META-INF/maven/plugin.xml` — the plugin descriptor
+- `target/generated-sources/annotations/com/example/GreetMojo$MojoFactory.java`
Review Comment:
⚠️ **Stale class name**: same issue — the generated class is `GreetMojoMojoFactory` (top-level), not `GreetMojo$MojoFactory` (nested). Update to match the actual output of the processor.
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4341445894
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
Review Comment:
⚠️ **`catch (Exception e)` too broad — still open from prior review.**
`writePluginXml()` declares `throws IOException, XMLStreamException`. Catching `Exception` here silently swallows any unchecked exception (NPE, `IllegalStateException`, etc.) that signals a programming error in the processor itself. The compiler guarantees all checked exceptions are covered by the specific types — use those:
```suggestion
} catch (IOException | XMLStreamException e) {
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
Review Comment:
🔒 **Missing `IS_SUPPORTING_EXTERNAL_ENTITIES=false` — still open from prior review.**
The OWASP XXE hardening triple requires all three properties. Setting only `SUPPORT_DTD=false` and `IS_REPLACING_ENTITY_REFERENCES=false` is insufficient — a compliant StAX implementation may still load external entity references unless this third property is also disabled. Add the missing property:
```suggestion
factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
factory.setProperty(javax.xml.stream.XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 — still open from prior review.**
`MyMojo$MojoFactory` uses `$`, which the JLS (§3.8) reserves for compiler-generated names (inner classes, lambdas, etc.). Tools such as ProGuard, bytecode manipulation frameworks, and some IDEs treat `$` as a synthetic class separator and may misclassify or refuse to handle these factory classes. Use a conventional separator instead:
```suggestion
String factorySimpleName = simpleName + "MojoFactory";
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 open from prior review.**
`(?m)^\\s*@` splits on **any** `@` at the start of a line — including `@author`, `@see`, `@link`, `@code`, email addresses in Javadoc text, and `@Override` annotations if the comment runs into the method declaration. Block Javadoc tags are a specific set of known keywords. Use a lookahead or explicit alternation to match only the tags you care about:
```suggestion
String[] parts = raw.split("(?m)^\\s*@(?=since|deprecated|param|return|throws|exception|author|see|version|serial)(?=\\b)");
```
This restricts splitting to recognized block-tag keywords and avoids false splits on `@link`, `@code`, email addresses, and annotations that may appear at line start.
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 — still open from prior review.**
The code copies parameter names and types from the mojo constructor but never calls `ctor.getThrownTypes()`. If any mojo constructor declares a checked exception (e.g. `MyMojo() throws IOException`), the generated factory constructor will fail to compile because `super(...)` propagates that exception but the factory ctor doesn't declare it.
Fix — collect thrown types and emit the `throws` clause:
```suggestion
List<? extends TypeMirror> thrown = ctor.getThrownTypes();
StringBuilder throwsClause = new StringBuilder();
for (int t = 0; t < thrown.size(); t++) {
if (t == 0) throwsClause.append(" throws ");
else throwsClause.append(", ");
throwsClause.append(thrown.get(t).toString());
}
pw.println(")" + throwsClause + " {");
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,1028 @@
+/*
+ * 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.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+
+import java.io.IOException;
+import java.io.InputStream;
+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>The processor reads plugin coordinates (groupId, artifactId, version, goalPrefix) from the
+ * project's {@code pom.xml}, located via the filer's source path. Any of these values can be
+ * overridden with compiler {@code -A} options:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — overrides the POM groupId</li>
+ * <li>{@code maven.plugin.artifactId} — overrides the POM artifactId</li>
+ * <li>{@code maven.plugin.version} — overrides the POM version</li>
+ * <li>{@code maven.plugin.goalPrefix} — overrides the derived goal prefix</li>
+ * </ul>
+ *
+ * <p>Goal prefix is derived from the artifactId by stripping the {@code maven-} prefix and
+ * {@code -plugin} / {@code -maven-plugin} suffix when not explicitly configured in the POM
+ * or via {@code -A}.
+ *
+ * <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<>();
+
+ /** Coordinates resolved lazily on first use (from POM + -A overrides). */
+ private PomCoordinates resolvedCoordinates;
+
+ @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.
+ PomCoordinates coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ PomCoordinates coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false; // do not claim the annotation
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution (POM + -A overrides)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolves plugin coordinates from the project {@code pom.xml}, with any
+ * {@code -A} compiler options taking precedence. The result is cached after
+ * the first successful resolution.
+ *
+ * @return resolved coordinates, or {@code null} if the POM cannot be found
+ * and no {@code -A} options are provided
+ */
+ PomCoordinates resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+ PomCoordinates pom = readPomCoordinates();
+
+ String groupId = override(getOption(OPT_GROUP_ID), pom != null ? pom.groupId : null);
+ String artifactId = override(getOption(OPT_ARTIFACT_ID), pom != null ? pom.artifactId : null);
+ String version = override(getOption(OPT_VERSION), pom != null ? pom.version : null);
+ String goalPrefix = override(
+ getOption(OPT_GOAL_PREFIX),
+ pom != null && pom.goalPrefix != null ? pom.goalPrefix : deriveGoalPrefix(artifactId));
+
+ if (groupId == null || artifactId == null || version == null) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Either place a pom.xml at the project root, or supply "
+ + "-Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version to the maven-compiler-plugin.");
+ return null;
+ }
+
+ resolvedCoordinates = new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ return resolvedCoordinates;
+ }
+
+ private static String override(String explicit, String fallback) {
+ return (explicit != null && !explicit.isEmpty()) ? explicit : fallback;
+ }
+
+ /**
+ * Attempts to read plugin coordinates from the nearest {@code pom.xml} reachable
+ * via the annotation processor filer's source path.
+ * Also reads {@code goalPrefix} from the {@code maven-plugin-plugin} configuration
+ * when present.
+ *
+ * @return parsed coordinates, or {@code null} if no {@code pom.xml} is found
+ */
+ PomCoordinates readPomCoordinates() {
+ for (StandardLocation loc :
+ new StandardLocation[] {StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT}) {
+ try {
+ FileObject fo = processingEnv.getFiler().getResource(loc, "", "pom.xml");
+ try (InputStream in = fo.openInputStream()) {
+ return parsePom(in);
+ }
+ } catch (IOException | UnsupportedOperationException ignored) {
+ // Try next location or fall back to -A options
+ } catch (RuntimeException e) {
+ // javac's ClientCodeWrapper can re-wrap UnsupportedOperationException as RuntimeException
+ if (!(e.getCause() instanceof UnsupportedOperationException)) {
+ throw e;
+ }
+ // Otherwise: filer doesn't support this location — try next
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Minimal StAX parser that extracts groupId, artifactId, version and the
+ * goalPrefix configured under
+ * {@code /project/build/plugins/plugin[artifactId='maven-plugin-plugin']/configuration/goalPrefix}.
+ */
+ static PomCoordinates parsePom(InputStream in) throws IOException {
+ try {
+ javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance();
+ factory.setProperty(javax.xml.stream.XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false);
+ XMLStreamReader reader = factory.createXMLStreamReader(in);
+ try {
+ return doParsePom(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (javax.xml.stream.XMLStreamException e) {
+ throw new IOException("Failed to parse pom.xml", e);
+ }
+ }
+
+ private static PomCoordinates doParsePom(XMLStreamReader r) throws javax.xml.stream.XMLStreamException {
+ String groupId = null;
+ String artifactId = null;
+ String version = null;
+ String goalPrefix = null;
+
+ // Simple depth-tracked path: only capture top-level <groupId>, <artifactId>, <version>
+ // and the goalPrefix inside the maven-plugin-plugin configuration.
+ List<String> path = new ArrayList<>();
+ String currentPluginArtifactId = null;
+ boolean inPluginPluginConfig = false;
+
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamReader.START_ELEMENT) {
+ path.add(r.getLocalName());
+
+ if (matches(path, "project", "groupId") && groupId == null) {
+ groupId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "artifactId") && artifactId == null) {
+ artifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "version") && version == null) {
+ version = r.getElementText();
+ path.remove(path.size() - 1);
+ } else if (matches(path, "project", "build", "plugins", "plugin", "artifactId")) {
+ currentPluginArtifactId = r.getElementText();
+ path.remove(path.size() - 1);
+ if ("maven-plugin-plugin".equals(currentPluginArtifactId)) {
+ inPluginPluginConfig = true;
+ }
+ } else if (inPluginPluginConfig
+ && matches(path, "project", "build", "plugins", "plugin", "configuration", "goalPrefix")) {
+ goalPrefix = r.getElementText();
+ path.remove(path.size() - 1);
+ }
+ } else if (event == XMLStreamReader.END_ELEMENT) {
+ String leaving = path.isEmpty() ? "" : path.get(path.size() - 1);
+ if (!path.isEmpty()) {
+ path.remove(path.size() - 1);
+ }
+ if ("plugin".equals(leaving)) {
+ inPluginPluginConfig = false;
+ currentPluginArtifactId = null;
+ }
+ }
+ }
+ if (artifactId == null) {
+ return null;
+ }
+ return new PomCoordinates(groupId, artifactId, version, goalPrefix);
+ }
+
+ private static boolean matches(List<String> path, String... expected) {
+ if (path.size() != expected.length) {
+ return false;
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (!expected[i].equals(path.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(PomCoordinates coords) throws IOException, XMLStreamException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+ String goalPrefix = coords.goalPrefix;
+
+ 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, PomCoordinates coords) throws IOException {
+ String groupId = coords.groupId;
+ String artifactId = coords.artifactId;
+ String version = coords.version;
+
+ 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 {
Review Comment:
🧹 **Dead code: 3-arg `writeElement` overload — still open from prior review.**
This method is never called externally. Every call site in the file explicitly passes an indent argument (2, 6, or 10) to the 4-arg overload. The 3-arg wrapper only calls itself internally (line 892) with `indent=4`, but no caller ever reaches it. Remove it:
```suggestion
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] Add maven-api-plugin-processor: JSR 269 annotation processor for Maven 4 plugin descriptors [maven]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #13658:
URL: https://github.com/apache/maven/pull/13658#discussion_r4341670087
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,768 @@
+/*
+ * 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.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;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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 PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0";
+
+ private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ 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);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ String[] coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
Review Comment:
⚠️ **`catch (Exception e)` too broad — still open from prior reviews.**
`writePluginXml()` declares `throws IOException, XMLStreamException`. Catching `Exception` here silently swallows any unchecked exception (`NullPointerException`, `IllegalStateException`, etc.) from the XML generation path, converting a processor bug into a misleading "Failed to generate plugin.xml" compiler diagnostic. Narrow to the declared checked exceptions:
```suggestion
} catch (IOException | XMLStreamException e) {
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,768 @@
+/*
+ * 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.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;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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 PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0";
+
+ private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ 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);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ String[] coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false;
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution
+ // -------------------------------------------------------------------------
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(String[] coords) throws IOException, XMLStreamException {
+ String groupId = coords[0];
+ String artifactId = coords[1];
+ String version = coords[2];
+ String goalPrefix = coords[3];
+
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(artifactId)
+ .groupId(groupId)
+ .artifactId(artifactId)
+ .version(version)
+ .goalPrefix(goalPrefix)
+ .mojos(mojos)
+ .build();
+
+ FileObject out =
+ processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml");
+ try (Writer w = out.openWriter()) {
+ writeDescriptorXml(w, descriptor);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null) {
+ return null;
+ }
+
+ String goal = mojoAnn.name();
+ if (goal.isEmpty()) {
+ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType);
+ return null;
+ }
+
+ String fqn = mojoType.getQualifiedName().toString();
+ JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+
+ MojoDescriptor.Builder builder = MojoDescriptor.newBuilder(true)
+ .goal(goal)
+ .implementation(fqn)
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+
+ if (!mojoAnn.defaultPhase().isEmpty()) {
+ builder.phase(mojoAnn.defaultPhase());
+ }
+ if (!mojoAnn.configurator().isEmpty()) {
+ builder.configurator(mojoAnn.configurator());
+ }
+ if (!mojoAnn.dependencyResolutionPathScopes().isEmpty()) {
+ builder.dependencyResolution(mojoAnn.dependencyResolutionPathScopes());
+ }
+ if (mojoAnn.dependencyCollection()) {
+ builder.dependencyCollection("true");
+ }
+ if (mojoDoc.description != null) {
+ builder.description(mojoDoc.description);
+ }
+ if (mojoDoc.since != null) {
+ builder.since(mojoDoc.since);
+ }
+ if (mojoDoc.deprecated != null) {
+ builder.deprecated(mojoDoc.deprecated);
+ }
+
+ // @Execute
+ Execute executeAnn = mojoType.getAnnotation(Execute.class);
+ if (executeAnn != null) {
+ if (!executeAnn.phase().isEmpty()) {
+ builder.executePhase(executeAnn.phase());
+ }
+ if (!executeAnn.goal().isEmpty()) {
+ builder.executeGoal(executeAnn.goal());
+ }
+ if (!executeAnn.lifecycle().isEmpty()) {
+ builder.executeLifecycle(executeAnn.lifecycle());
+ }
+ }
+
+ builder.parameters(collectParameters(mojoType));
+ builder.resolutions(collectResolutions(mojoType));
+ builder.afterLinks(collectAfterLinks(mojoType));
+
+ return builder.build();
+ }
+
+ // -------------------------------------------------------------------------
+ // Parameter collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> 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;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter paramAnn =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (paramAnn == null) {
+ continue;
+ }
+ String paramName =
+ paramAnn.name().isEmpty() ? enclosed.getSimpleName().toString() : paramAnn.name();
+ if (seen.contains(paramName)) {
+ continue;
+ }
+ seen.add(paramName);
+
+ String type;
+ if (enclosed.getKind() == ElementKind.FIELD) {
+ type = ((VariableElement) enclosed).asType().toString();
+ } else {
+ ExecutableElement method = (ExecutableElement) enclosed;
+ type = !method.getParameters().isEmpty()
+ ? method.getParameters().get(0).asType().toString()
+ : method.getReturnType().toString();
+ }
+
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+
+ String expression = paramAnn.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(paramName)
+ .type(type)
+ .required(paramAnn.required())
+ .editable(!paramAnn.readonly());
+ if (!paramAnn.alias().isEmpty()) {
+ pb.alias(paramAnn.alias());
+ }
+ if (!expression.isEmpty()) {
+ pb.expression(expression);
+ }
+ if (!paramAnn.defaultValue().isEmpty()) {
+ pb.defaultValue(paramAnn.defaultValue());
+ }
+ if (doc.description != null) {
+ pb.description(doc.description);
+ }
+ if (doc.since != null) {
+ pb.since(doc.since);
+ }
+ if (doc.deprecated != null) {
+ pb.deprecated(doc.deprecated);
+ }
+ result.add(pb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Resolution collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> 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;
+ }
+ Resolution resAnn = enclosed.getAnnotation(Resolution.class);
+ if (resAnn == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (seen.contains(fieldName)) {
+ continue;
+ }
+ seen.add(fieldName);
+
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ if (!resAnn.pathScope().isEmpty()) {
+ rb.pathScope(resAnn.pathScope());
+ }
+ if (!resAnn.requestType().isEmpty()) {
+ rb.requestType(resAnn.requestType());
+ }
+ result.add(rb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // @After collection
+ // -------------------------------------------------------------------------
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>();
+ for (After after : afters) {
+ AfterLink.Builder ab = AfterLink.newBuilder(true)
+ .phase(after.phase())
+ .type(after.type().name());
+ if (!after.scope().isEmpty()) {
+ ab.scope(after.scope());
+ }
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Factory class generation
+ // -------------------------------------------------------------------------
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().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;
Review Comment:
💡 **`$` in generated class names — still open from prior reviews.**
`MyMojo$MojoFactory` uses `$`, which the JLS (§3.8) reserves for compiler-generated names (inner classes, lambdas, bridge methods). Tools such as ProGuard, Byte Buddy, and some IDEs treat `$` as a nested-class separator and may misclassify these factories as inner classes of `MyMojo`. The canonical separator for generated peer classes that avoids ambiguity is `_`:
```suggestion
String factorySimpleName = simpleName + "_MojoFactory";
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,768 @@
+/*
+ * 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.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;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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 PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0";
+
+ private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ 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);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ String[] coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false;
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution
+ // -------------------------------------------------------------------------
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(String[] coords) throws IOException, XMLStreamException {
+ String groupId = coords[0];
+ String artifactId = coords[1];
+ String version = coords[2];
+ String goalPrefix = coords[3];
+
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(artifactId)
+ .groupId(groupId)
+ .artifactId(artifactId)
+ .version(version)
+ .goalPrefix(goalPrefix)
+ .mojos(mojos)
+ .build();
+
+ FileObject out =
+ processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml");
+ try (Writer w = out.openWriter()) {
+ writeDescriptorXml(w, descriptor);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null) {
+ return null;
+ }
+
+ String goal = mojoAnn.name();
+ if (goal.isEmpty()) {
+ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType);
+ return null;
+ }
+
+ String fqn = mojoType.getQualifiedName().toString();
+ JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+
+ MojoDescriptor.Builder builder = MojoDescriptor.newBuilder(true)
+ .goal(goal)
+ .implementation(fqn)
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+
+ if (!mojoAnn.defaultPhase().isEmpty()) {
+ builder.phase(mojoAnn.defaultPhase());
+ }
+ if (!mojoAnn.configurator().isEmpty()) {
+ builder.configurator(mojoAnn.configurator());
+ }
+ if (!mojoAnn.dependencyResolutionPathScopes().isEmpty()) {
+ builder.dependencyResolution(mojoAnn.dependencyResolutionPathScopes());
+ }
+ if (mojoAnn.dependencyCollection()) {
+ builder.dependencyCollection("true");
+ }
+ if (mojoDoc.description != null) {
+ builder.description(mojoDoc.description);
+ }
+ if (mojoDoc.since != null) {
+ builder.since(mojoDoc.since);
+ }
+ if (mojoDoc.deprecated != null) {
+ builder.deprecated(mojoDoc.deprecated);
+ }
+
+ // @Execute
+ Execute executeAnn = mojoType.getAnnotation(Execute.class);
+ if (executeAnn != null) {
+ if (!executeAnn.phase().isEmpty()) {
+ builder.executePhase(executeAnn.phase());
+ }
+ if (!executeAnn.goal().isEmpty()) {
+ builder.executeGoal(executeAnn.goal());
+ }
+ if (!executeAnn.lifecycle().isEmpty()) {
+ builder.executeLifecycle(executeAnn.lifecycle());
+ }
+ }
+
+ builder.parameters(collectParameters(mojoType));
+ builder.resolutions(collectResolutions(mojoType));
+ builder.afterLinks(collectAfterLinks(mojoType));
+
+ return builder.build();
+ }
+
+ // -------------------------------------------------------------------------
+ // Parameter collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> 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;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter paramAnn =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (paramAnn == null) {
+ continue;
+ }
+ String paramName =
+ paramAnn.name().isEmpty() ? enclosed.getSimpleName().toString() : paramAnn.name();
+ if (seen.contains(paramName)) {
+ continue;
+ }
+ seen.add(paramName);
+
+ String type;
+ if (enclosed.getKind() == ElementKind.FIELD) {
+ type = ((VariableElement) enclosed).asType().toString();
+ } else {
+ ExecutableElement method = (ExecutableElement) enclosed;
+ type = !method.getParameters().isEmpty()
+ ? method.getParameters().get(0).asType().toString()
+ : method.getReturnType().toString();
+ }
+
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+
+ String expression = paramAnn.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(paramName)
+ .type(type)
+ .required(paramAnn.required())
+ .editable(!paramAnn.readonly());
+ if (!paramAnn.alias().isEmpty()) {
+ pb.alias(paramAnn.alias());
+ }
+ if (!expression.isEmpty()) {
+ pb.expression(expression);
+ }
+ if (!paramAnn.defaultValue().isEmpty()) {
+ pb.defaultValue(paramAnn.defaultValue());
+ }
+ if (doc.description != null) {
+ pb.description(doc.description);
+ }
+ if (doc.since != null) {
+ pb.since(doc.since);
+ }
+ if (doc.deprecated != null) {
+ pb.deprecated(doc.deprecated);
+ }
+ result.add(pb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Resolution collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> 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;
+ }
+ Resolution resAnn = enclosed.getAnnotation(Resolution.class);
+ if (resAnn == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (seen.contains(fieldName)) {
+ continue;
+ }
+ seen.add(fieldName);
+
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ if (!resAnn.pathScope().isEmpty()) {
+ rb.pathScope(resAnn.pathScope());
+ }
+ if (!resAnn.requestType().isEmpty()) {
+ rb.requestType(resAnn.requestType());
+ }
+ result.add(rb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // @After collection
+ // -------------------------------------------------------------------------
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>();
+ for (After after : afters) {
+ AfterLink.Builder ab = AfterLink.newBuilder(true)
+ .phase(after.phase())
+ .type(after.type().name());
+ if (!after.scope().isEmpty()) {
+ ab.scope(after.scope());
+ }
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Factory class generation
+ // -------------------------------------------------------------------------
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().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 = coords[0] + ":" + coords[1] + ":" + coords[2] + ":" + mojoAnn.name();
+
+ 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("@org.apache.maven.api.di.Named(\"" + roleHint + "\")");
+ pw.println("public class " + factorySimpleName + " extends " + simpleName + " {");
+ pw.println();
+
+ if (!ctors.isEmpty()
+ && !(ctors.size() == 1 && ctors.get(0).getParameters().isEmpty())) {
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue;
+ }
+ 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);
+ if (i > 0) {
+ pw.print(", ");
+ args.append(", ");
+ }
+ pw.print(param.asType() + " " + param.getSimpleName());
+ args.append(param.getSimpleName());
+ }
+ pw.println(") {");
+ pw.println(" super(" + args + ");");
+ pw.println(" }");
+ pw.println();
+ }
+ }
+
+ pw.println("}");
+ }
+
+ generatedFactoryNames.add(factoryFqn);
+ note("Generated factory " + factoryFqn + " for goal '" + mojoAnn.name() + "'");
+ }
+
+ // -------------------------------------------------------------------------
+ // XML writing (using JDK XMLStreamWriter to avoid woodstox dependency)
+ // -------------------------------------------------------------------------
+
+ private static void writeDescriptorXml(Writer w, PluginDescriptor descriptor)
+ throws IOException, XMLStreamException {
+ 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", descriptor.getName(), 2);
+ writeElement(xml, "groupId", descriptor.getGroupId(), 2);
+ writeElement(xml, "artifactId", descriptor.getArtifactId(), 2);
+ writeElement(xml, "version", descriptor.getVersion(), 2);
+ writeElement(xml, "goalPrefix", descriptor.getGoalPrefix(), 2);
+
+ if (descriptor.getMojos() != null && !descriptor.getMojos().isEmpty()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("mojos");
+ xml.writeCharacters("\n");
+ for (MojoDescriptor mojo : descriptor.getMojos()) {
+ writeMojoXml(xml, mojo);
+ }
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ xml.writeEndDocument();
+ } finally {
+ xml.close();
+ }
+ }
+
+ private static void writeMojoXml(XMLStreamWriter xml, MojoDescriptor mojo) throws XMLStreamException {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("mojo");
+ xml.writeCharacters("\n");
+
+ writeElement(xml, "goal", mojo.getGoal(), 6);
+ writeOptional(xml, "phase", mojo.getPhase(), 6);
+ writeElement(xml, "implementation", mojo.getImplementation(), 6);
+ writeElement(xml, "language", mojo.getLanguage(), 6);
+ writeOptional(xml, "description", mojo.getDescription(), 6);
+ writeOptional(xml, "since", mojo.getSince(), 6);
+ writeOptional(xml, "deprecated", mojo.getDeprecated(), 6);
+ writeElement(xml, "projectRequired", String.valueOf(mojo.isProjectRequired()), 6);
+ writeElement(xml, "onlineRequired", String.valueOf(mojo.isOnlineRequired()), 6);
+ writeElement(xml, "aggregator", String.valueOf(mojo.isAggregator()), 6);
+ writeOptional(xml, "configurator", mojo.getConfigurator(), 6);
+ writeOptional(xml, "executePhase", mojo.getExecutePhase(), 6);
+ writeOptional(xml, "executeGoal", mojo.getExecuteGoal(), 6);
+ writeOptional(xml, "executeLifecycle", mojo.getExecuteLifecycle(), 6);
+ writeOptional(xml, "dependencyResolution", mojo.getDependencyResolution(), 6);
+ writeOptional(xml, "dependencyCollection", mojo.getDependencyCollection(), 6);
+
+ if (mojo.getParameters() != null && !mojo.getParameters().isEmpty()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("parameters");
+ xml.writeCharacters("\n");
+ for (org.apache.maven.api.plugin.descriptor.Parameter p : mojo.getParameters()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("parameter");
+ xml.writeCharacters("\n");
+ writeElement(xml, "name", p.getName(), 10);
+ writeOptional(xml, "alias", p.getAlias(), 10);
+ writeElement(xml, "type", p.getType(), 10);
+ writeElement(xml, "required", String.valueOf(p.isRequired()), 10);
+ writeElement(xml, "editable", String.valueOf(p.isEditable()), 10);
+ writeOptional(xml, "description", p.getDescription(), 10);
+ writeOptional(xml, "since", p.getSince(), 10);
+ writeOptional(xml, "deprecated", p.getDeprecated(), 10);
+ writeOptional(xml, "expression", p.getExpression(), 10);
+ writeOptional(xml, "defaultValue", p.getDefaultValue(), 10);
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ if (mojo.getResolutions() != null && !mojo.getResolutions().isEmpty()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("resolutions");
+ xml.writeCharacters("\n");
+ for (org.apache.maven.api.plugin.descriptor.Resolution r : mojo.getResolutions()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("resolution");
+ xml.writeCharacters("\n");
+ writeElement(xml, "field", r.getField(), 10);
+ writeOptional(xml, "pathScope", r.getPathScope(), 10);
+ writeOptional(xml, "requestType", r.getRequestType(), 10);
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ if (mojo.getAfterLinks() != null && !mojo.getAfterLinks().isEmpty()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("afterLinks");
+ xml.writeCharacters("\n");
+ for (AfterLink a : mojo.getAfterLinks()) {
+ xml.writeCharacters(" ");
+ xml.writeStartElement("afterLink");
+ xml.writeCharacters("\n");
+ writeOptional(xml, "phase", a.getPhase(), 10);
+ writeElement(xml, "type", a.getType(), 10);
+ writeOptional(xml, "scope", a.getScope(), 10);
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ xml.writeCharacters(" ");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ private static void writeElement(XMLStreamWriter xml, String tag, String value, int indent)
+ throws XMLStreamException {
+ xml.writeCharacters(" ".repeat(indent));
+ xml.writeStartElement(tag);
+ xml.writeCharacters(value != null ? value : "");
+ xml.writeEndElement();
+ xml.writeCharacters("\n");
+ }
+
+ private static void writeOptional(XMLStreamWriter xml, String tag, String value, int indent)
+ throws XMLStreamException {
+ if (value != null && !value.isEmpty()) {
+ writeElement(xml, tag, value, indent);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Javadoc parsing
+ // -------------------------------------------------------------------------
+
+ private static JavadocInfo parseJavadoc(String raw) {
+ JavadocInfo info = new JavadocInfo();
+ if (raw == null || raw.isBlank()) {
+ return info;
+ }
+ String[] parts = raw.split("(?m)^\\s*@");
+ String body = parts[0];
Review Comment:
⚠️ **Greedy Javadoc `@` regex — still open from prior reviews.**
`(?m)^\s*@` splits on **any** `@` at the start of a line — including `@Override` annotations in code snippets embedded in Javadoc, email addresses (`@example.com`), and `{@link ...}` / `{@code ...}` that wrap to a new line. This silently truncates mojo descriptions. Restrict to known Javadoc block-tag keywords:
```suggestion
String[] parts = raw.split("(?m)^\\s*@(?=param|return|throws|exception|since|deprecated|see|author|version|serial|serialField|serialData)\\b");
```
##########
impl/maven-plugin-mixin/src/main/java/org/apache/maven/plugin/processor/MojoDescriptorProcessor.java:
##########
@@ -0,0 +1,768 @@
+/*
+ * 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.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;
+
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Execute;
+import org.apache.maven.api.plugin.annotations.Mojo;
+import org.apache.maven.api.plugin.annotations.Resolution;
+import org.apache.maven.api.plugin.descriptor.AfterLink;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+
+/**
+ * JSR 269 annotation processor that generates Maven plugin descriptors ({@code META-INF/maven/plugin.xml})
+ * and DI factory classes at compile time from {@link Mojo @Mojo}-annotated classes.
+ *
+ * <p>The processor reads plugin coordinates from compiler {@code -A} options, typically
+ * provided by the {@code maven-plugin-mixin}:
+ * <ul>
+ * <li>{@code maven.plugin.groupId} — plugin group ID (required)</li>
+ * <li>{@code maven.plugin.artifactId} — plugin artifact ID (required)</li>
+ * <li>{@code maven.plugin.version} — plugin version (required)</li>
+ * <li>{@code maven.plugin.goalPrefix} — goal prefix (optional, derived from artifactId)</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 PLUGIN_NS = "http://maven.apache.org/PLUGIN/2.0.0";
+
+ private final Map<String, TypeElement> mojoElements = new LinkedHashMap<>();
+ private final Set<String> generatedFactoryNames = new LinkedHashSet<>();
+ private String[] resolvedCoordinates;
+
+ @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();
+ if (generatedFactoryNames.contains(fqn)) {
+ continue;
+ }
+ 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);
+ String[] coords = resolveCoordinates();
+ if (coords != null) {
+ try {
+ generateFactory(typeElement, coords);
+ } catch (IOException e) {
+ logError("Failed to generate factory for " + fqn, e);
+ }
+ }
+ }
+ }
+ }
+
+ if (roundEnv.processingOver()) {
+ if (mojoElements.isEmpty()) {
+ return false;
+ }
+ String[] coords = resolveCoordinates();
+ if (coords == null) {
+ return false;
+ }
+ try {
+ writePluginXml(coords);
+ } catch (Exception e) {
+ logError("Failed to generate plugin.xml", e);
+ }
+ }
+
+ return false;
+ }
+
+ // -------------------------------------------------------------------------
+ // Coordinate resolution
+ // -------------------------------------------------------------------------
+
+ String[] resolveCoordinates() {
+ if (resolvedCoordinates != null) {
+ return resolvedCoordinates;
+ }
+
+ String groupId = getOption(OPT_GROUP_ID);
+ String artifactId = getOption(OPT_ARTIFACT_ID);
+ String version = getOption(OPT_VERSION);
+ String goalPrefix = getOption(OPT_GOAL_PREFIX);
+ if (goalPrefix == null || goalPrefix.isEmpty()) {
+ goalPrefix = deriveGoalPrefix(artifactId);
+ }
+
+ if (groupId == null
+ || groupId.isEmpty()
+ || artifactId == null
+ || artifactId.isEmpty()
+ || version == null
+ || version.isEmpty()) {
+ processingEnv
+ .getMessager()
+ .printMessage(
+ Diagnostic.Kind.ERROR,
+ "maven-plugin-mixin: could not determine plugin coordinates. "
+ + "Supply -Amaven.plugin.groupId, -Amaven.plugin.artifactId and "
+ + "-Amaven.plugin.version via the maven-plugin-mixin.");
+ return null;
+ }
+
+ resolvedCoordinates = new String[] {groupId, artifactId, version, goalPrefix};
+ return resolvedCoordinates;
+ }
+
+ private String getOption(String key) {
+ return processingEnv.getOptions().get(key);
+ }
+
+ // -------------------------------------------------------------------------
+ // plugin.xml generation
+ // -------------------------------------------------------------------------
+
+ private void writePluginXml(String[] coords) throws IOException, XMLStreamException {
+ String groupId = coords[0];
+ String artifactId = coords[1];
+ String version = coords[2];
+ String goalPrefix = coords[3];
+
+ List<MojoDescriptor> mojos = new ArrayList<>();
+ for (TypeElement mojoType : mojoElements.values()) {
+ MojoDescriptor mojo = buildMojoDescriptor(mojoType);
+ if (mojo != null) {
+ mojos.add(mojo);
+ }
+ }
+
+ PluginDescriptor descriptor = PluginDescriptor.newBuilder(true)
+ .name(artifactId)
+ .groupId(groupId)
+ .artifactId(artifactId)
+ .version(version)
+ .goalPrefix(goalPrefix)
+ .mojos(mojos)
+ .build();
+
+ FileObject out =
+ processingEnv.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/maven/plugin.xml");
+ try (Writer w = out.openWriter()) {
+ writeDescriptorXml(w, descriptor);
+ }
+ note("Generated META-INF/maven/plugin.xml with " + mojos.size() + " mojo(s)");
+ }
+
+ private MojoDescriptor buildMojoDescriptor(TypeElement mojoType) {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null) {
+ return null;
+ }
+
+ String goal = mojoAnn.name();
+ if (goal.isEmpty()) {
+ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mojo.name() must not be empty", mojoType);
+ return null;
+ }
+
+ String fqn = mojoType.getQualifiedName().toString();
+ JavadocInfo mojoDoc = parseJavadoc(processingEnv.getElementUtils().getDocComment(mojoType));
+
+ MojoDescriptor.Builder builder = MojoDescriptor.newBuilder(true)
+ .goal(goal)
+ .implementation(fqn)
+ .projectRequired(mojoAnn.projectRequired())
+ .onlineRequired(mojoAnn.onlineRequired())
+ .aggregator(mojoAnn.aggregator());
+
+ if (!mojoAnn.defaultPhase().isEmpty()) {
+ builder.phase(mojoAnn.defaultPhase());
+ }
+ if (!mojoAnn.configurator().isEmpty()) {
+ builder.configurator(mojoAnn.configurator());
+ }
+ if (!mojoAnn.dependencyResolutionPathScopes().isEmpty()) {
+ builder.dependencyResolution(mojoAnn.dependencyResolutionPathScopes());
+ }
+ if (mojoAnn.dependencyCollection()) {
+ builder.dependencyCollection("true");
+ }
+ if (mojoDoc.description != null) {
+ builder.description(mojoDoc.description);
+ }
+ if (mojoDoc.since != null) {
+ builder.since(mojoDoc.since);
+ }
+ if (mojoDoc.deprecated != null) {
+ builder.deprecated(mojoDoc.deprecated);
+ }
+
+ // @Execute
+ Execute executeAnn = mojoType.getAnnotation(Execute.class);
+ if (executeAnn != null) {
+ if (!executeAnn.phase().isEmpty()) {
+ builder.executePhase(executeAnn.phase());
+ }
+ if (!executeAnn.goal().isEmpty()) {
+ builder.executeGoal(executeAnn.goal());
+ }
+ if (!executeAnn.lifecycle().isEmpty()) {
+ builder.executeLifecycle(executeAnn.lifecycle());
+ }
+ }
+
+ builder.parameters(collectParameters(mojoType));
+ builder.resolutions(collectResolutions(mojoType));
+ builder.afterLinks(collectAfterLinks(mojoType));
+
+ return builder.build();
+ }
+
+ // -------------------------------------------------------------------------
+ // Parameter collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Parameter> collectParameters(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Parameter> 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;
+ }
+ org.apache.maven.api.plugin.annotations.Parameter paramAnn =
+ enclosed.getAnnotation(org.apache.maven.api.plugin.annotations.Parameter.class);
+ if (paramAnn == null) {
+ continue;
+ }
+ String paramName =
+ paramAnn.name().isEmpty() ? enclosed.getSimpleName().toString() : paramAnn.name();
+ if (seen.contains(paramName)) {
+ continue;
+ }
+ seen.add(paramName);
+
+ String type;
+ if (enclosed.getKind() == ElementKind.FIELD) {
+ type = ((VariableElement) enclosed).asType().toString();
+ } else {
+ ExecutableElement method = (ExecutableElement) enclosed;
+ type = !method.getParameters().isEmpty()
+ ? method.getParameters().get(0).asType().toString()
+ : method.getReturnType().toString();
+ }
+
+ JavadocInfo doc = parseJavadoc(processingEnv.getElementUtils().getDocComment(enclosed));
+
+ String expression = paramAnn.property();
+ if (!expression.isEmpty() && !expression.startsWith("${")) {
+ expression = "${" + expression + "}";
+ }
+
+ org.apache.maven.api.plugin.descriptor.Parameter.Builder pb =
+ org.apache.maven.api.plugin.descriptor.Parameter.newBuilder(true)
+ .name(paramName)
+ .type(type)
+ .required(paramAnn.required())
+ .editable(!paramAnn.readonly());
+ if (!paramAnn.alias().isEmpty()) {
+ pb.alias(paramAnn.alias());
+ }
+ if (!expression.isEmpty()) {
+ pb.expression(expression);
+ }
+ if (!paramAnn.defaultValue().isEmpty()) {
+ pb.defaultValue(paramAnn.defaultValue());
+ }
+ if (doc.description != null) {
+ pb.description(doc.description);
+ }
+ if (doc.since != null) {
+ pb.since(doc.since);
+ }
+ if (doc.deprecated != null) {
+ pb.deprecated(doc.deprecated);
+ }
+ result.add(pb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Resolution collection
+ // -------------------------------------------------------------------------
+
+ private List<org.apache.maven.api.plugin.descriptor.Resolution> collectResolutions(TypeElement mojoType) {
+ List<org.apache.maven.api.plugin.descriptor.Resolution> 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;
+ }
+ Resolution resAnn = enclosed.getAnnotation(Resolution.class);
+ if (resAnn == null) {
+ continue;
+ }
+ String fieldName = enclosed.getSimpleName().toString();
+ if (seen.contains(fieldName)) {
+ continue;
+ }
+ seen.add(fieldName);
+
+ org.apache.maven.api.plugin.descriptor.Resolution.Builder rb =
+ org.apache.maven.api.plugin.descriptor.Resolution.newBuilder(true)
+ .field(fieldName);
+ if (!resAnn.pathScope().isEmpty()) {
+ rb.pathScope(resAnn.pathScope());
+ }
+ if (!resAnn.requestType().isEmpty()) {
+ rb.requestType(resAnn.requestType());
+ }
+ result.add(rb.build());
+ }
+ current = superTypeElement(current);
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // @After collection
+ // -------------------------------------------------------------------------
+
+ private List<AfterLink> collectAfterLinks(TypeElement mojoType) {
+ After[] afters = mojoType.getAnnotationsByType(After.class);
+ if (afters.length == 0) {
+ return List.of();
+ }
+ List<AfterLink> result = new ArrayList<>();
+ for (After after : afters) {
+ AfterLink.Builder ab = AfterLink.newBuilder(true)
+ .phase(after.phase())
+ .type(after.type().name());
+ if (!after.scope().isEmpty()) {
+ ab.scope(after.scope());
+ }
+ result.add(ab.build());
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Factory class generation
+ // -------------------------------------------------------------------------
+
+ private void generateFactory(TypeElement mojoType, String[] coords) throws IOException {
+ Mojo mojoAnn = mojoType.getAnnotation(Mojo.class);
+ if (mojoAnn == null || mojoAnn.name().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 = coords[0] + ":" + coords[1] + ":" + coords[2] + ":" + mojoAnn.name();
+
+ 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("@org.apache.maven.api.di.Named(\"" + roleHint + "\")");
+ pw.println("public class " + factorySimpleName + " extends " + simpleName + " {");
+ pw.println();
+
+ if (!ctors.isEmpty()
+ && !(ctors.size() == 1 && ctors.get(0).getParameters().isEmpty())) {
+ for (ExecutableElement ctor : ctors) {
+ if (ctor.getParameters().isEmpty()) {
+ continue;
+ }
+ 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);
+ if (i > 0) {
+ pw.print(", ");
+ args.append(", ");
+ }
+ pw.print(param.asType() + " " + param.getSimpleName());
+ args.append(param.getSimpleName());
+ }
+ pw.println(") {");
+ pw.println(" super(" + args + ");");
Review Comment:
🐛 **Factory constructor missing `throws` clause — still open from prior reviews.**
The parameter loop copies parameter names and types but never calls `ctor.getThrownTypes()`. If a `@Mojo` class declares a checked exception on its constructor (e.g. `MyMojo() throws IOException`), the generated factory constructor emits `public MyMojo_MojoFactory(...) {` — which the compiler rejects because `super(...)` propagates the checked exception but the subclass constructor doesn't declare it.
Fix — emit the `throws` clause before the opening brace:
```suggestion
List<? extends TypeMirror> thrown = ctor.getThrownTypes();
if (!thrown.isEmpty()) {
pw.print(") throws ");
pw.print(thrown.stream()
.map(Object::toString)
.collect(java.util.stream.Collectors.joining(", ")));
}
pw.println(") {");
```
--
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: issues-unsubscribe@maven.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org