Add initial implementation of Quarkus Reflection Import Plugin

This commit is contained in:
2026-04-24 09:23:59 +02:00
commit a31f8738d8
3 changed files with 509 additions and 0 deletions
@@ -0,0 +1,368 @@
package de.rsitservice.quarkus.reflection;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Mojo(name = "generate-reflection-config", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true)
public class ReflectionImportMojo extends AbstractMojo {
private static final Pattern IMPORT_PATTERN =
Pattern.compile("^\\s*import\\s+(static\\s+)?([a-zA-Z_][\\w$]*(?:\\.[a-zA-Z_][\\w$]*)+)\\s*;\\s*$");
private static final TypeReference<List<ReflectionConfigEntry>> ENTRY_LIST_TYPE = new TypeReference<>() {
};
/** Current Maven project context. */
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
/** Source directories that will be scanned for Java imports. Defaults to project compile roots. */
@Parameter(defaultValue = "${project.compileSourceRoots}")
private List<String> sourceDirectories;
/** Package prefixes to match against discovered imports (required). */
@Parameter(property = "importPackages", required = true)
private List<String> importPackages;
/** Build output directory from the Maven project. */
@Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true)
private File outputDirectory;
/** Optional output file path for the generated reflection config. */
@Parameter
private File reflectionConfigFile;
/** Optional extra reflection config files that should be merged before writing output. */
@Parameter
private List<File> additionalReflectionConfigInputs;
/** Value used for new entries: allDeclaredConstructors. */
@Parameter(defaultValue = "true")
private boolean allDeclaredConstructors;
/** Value used for new entries: allDeclaredMethods. */
@Parameter(defaultValue = "true")
private boolean allDeclaredMethods;
/** Value used for new entries: allDeclaredFields. */
@Parameter(defaultValue = "true")
private boolean allDeclaredFields;
/** Skip execution when true. */
@Parameter(defaultValue = "false")
private boolean skip;
private final ObjectMapper objectMapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
if (skip) {
getLog().info("Skipping reflection import scan (skip=true).");
return;
}
List<String> normalizedPrefixes = normalizeImportPrefixes(importPackages);
if (normalizedPrefixes.isEmpty()) {
throw new MojoFailureException("Parameter 'importPackages' is required and must not be empty.");
}
File targetFile = resolveTargetFile();
Set<String> importsFromCode = scanImports(normalizedPrefixes);
Map<String, ReflectionConfigEntry> mergedEntries = loadExistingEntries(targetFile);
int addedCount = 0;
for (String fqcn : importsFromCode) {
ReflectionConfigEntry existing = mergedEntries.get(fqcn);
if (existing != null) {
continue;
}
mergedEntries.put(fqcn, ReflectionConfigEntry.forClass(
fqcn,
allDeclaredConstructors,
allDeclaredMethods,
allDeclaredFields));
addedCount++;
}
writeReflectionConfig(targetFile, mergedEntries.values());
getLog().info(String.format(Locale.ROOT,
"Reflection config written to %s (imports found: %d, new entries: %d, total entries: %d)",
targetFile.getAbsolutePath(), importsFromCode.size(), addedCount, mergedEntries.size()));
}
private List<String> normalizeImportPrefixes(List<String> configuredPrefixes) {
if (configuredPrefixes == null) {
return List.of();
}
return configuredPrefixes.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.collect(Collectors.toList());
}
private File resolveTargetFile() {
if (reflectionConfigFile != null) {
return reflectionConfigFile;
}
return new File(outputDirectory,
"META-INF/native-image/"
+ project.getGroupId() + "/"
+ project.getArtifactId() + "/"
+ "reflection-config.json");
}
private Set<String> scanImports(List<String> normalizedPrefixes) throws MojoExecutionException {
List<Path> roots = resolveSourceRoots();
Set<String> matchedImports = new TreeSet<>();
for (Path root : roots) {
if (!Files.isDirectory(root)) {
getLog().debug("Skipping non-existing source directory: " + root);
continue;
}
try (Stream<Path> files = Files.walk(root)) {
files.filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".java"))
.forEach(path -> collectImportsFromFile(path, normalizedPrefixes, matchedImports));
} catch (IOException e) {
throw new MojoExecutionException("Error while scanning source directory: " + root, e);
}
}
return matchedImports;
}
private List<Path> resolveSourceRoots() {
List<String> configured = sourceDirectories == null ? List.of() : sourceDirectories;
List<Path> roots = new ArrayList<>();
for (String rawRoot : configured) {
if (rawRoot == null || rawRoot.trim().isEmpty()) {
continue;
}
roots.add(Paths.get(rawRoot));
}
if (roots.isEmpty()) {
roots.add(Paths.get(project.getBasedir().getAbsolutePath(), "src", "main", "java"));
}
return roots;
}
private void collectImportsFromFile(Path javaFile,
List<String> normalizedPrefixes,
Set<String> matchedImports) {
List<String> lines;
try {
lines = Files.readAllLines(javaFile);
} catch (IOException e) {
getLog().warn("Cannot read file: " + javaFile + " - " + e.getMessage());
return;
}
for (String line : lines) {
Matcher matcher = IMPORT_PATTERN.matcher(line);
if (!matcher.matches()) {
continue;
}
boolean isStaticImport = matcher.group(1) != null;
String importedType = matcher.group(2);
if (isStaticImport) {
continue;
}
if (matchesAnyPrefix(importedType, normalizedPrefixes)) {
matchedImports.add(importedType);
}
}
}
private boolean matchesAnyPrefix(String importedType, List<String> normalizedPrefixes) {
for (String prefix : normalizedPrefixes) {
if (importedType.equals(prefix) || importedType.startsWith(prefix + ".")) {
return true;
}
}
return false;
}
private Map<String, ReflectionConfigEntry> loadExistingEntries(File targetFile) throws MojoExecutionException {
Map<String, ReflectionConfigEntry> merged = new LinkedHashMap<>();
Set<File> inputs = new LinkedHashSet<>();
addIfExists(inputs, targetFile);
Path conventionalConfigPath = Paths.get(
project.getBasedir().getAbsolutePath(),
"src", "main", "resources",
"META-INF", "native-image",
project.getGroupId(),
project.getArtifactId(),
"reflection-config.json");
addIfExists(inputs, conventionalConfigPath.toFile());
Path fallbackConfigPath = Paths.get(
project.getBasedir().getAbsolutePath(),
"src", "main", "resources",
"META-INF", "native-image",
"reflection-config.json");
addIfExists(inputs, fallbackConfigPath.toFile());
if (additionalReflectionConfigInputs != null) {
for (File file : additionalReflectionConfigInputs) {
addIfExists(inputs, file);
}
}
for (File input : inputs) {
List<ReflectionConfigEntry> entries = readEntries(input);
for (ReflectionConfigEntry entry : entries) {
if (entry.getName() == null || entry.getName().trim().isEmpty()) {
continue;
}
merged.putIfAbsent(entry.getName(), entry);
}
}
return merged;
}
private void addIfExists(Set<File> files, File file) {
if (file != null && file.isFile()) {
files.add(file.getAbsoluteFile());
}
}
private List<ReflectionConfigEntry> readEntries(File file) throws MojoExecutionException {
try {
return objectMapper.readValue(file, ENTRY_LIST_TYPE);
} catch (IOException e) {
throw new MojoExecutionException("Cannot read reflection config: " + file.getAbsolutePath(), e);
}
}
private void writeReflectionConfig(File targetFile,
Collection<ReflectionConfigEntry> entries) throws MojoExecutionException {
File parent = targetFile.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new MojoExecutionException("Cannot create output directory: " + parent.getAbsolutePath());
}
List<ReflectionConfigEntry> sortedEntries = entries.stream()
.sorted((left, right) -> left.getName().compareToIgnoreCase(right.getName()))
.collect(Collectors.toList());
try {
objectMapper.writeValue(targetFile, sortedEntries);
} catch (IOException e) {
throw new MojoExecutionException("Cannot write reflection config: " + targetFile.getAbsolutePath(), e);
}
}
public static final class ReflectionConfigEntry {
private String name;
private Boolean allDeclaredConstructors;
private Boolean allDeclaredMethods;
private Boolean allDeclaredFields;
private final Map<String, Object> additional = new LinkedHashMap<>();
public static ReflectionConfigEntry forClass(String fqcn,
boolean constructors,
boolean methods,
boolean fields) {
ReflectionConfigEntry entry = new ReflectionConfigEntry();
entry.setName(fqcn);
entry.setAllDeclaredConstructors(constructors);
entry.setAllDeclaredMethods(methods);
entry.setAllDeclaredFields(fields);
return entry;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Boolean getAllDeclaredConstructors() {
return allDeclaredConstructors;
}
public void setAllDeclaredConstructors(Boolean allDeclaredConstructors) {
this.allDeclaredConstructors = allDeclaredConstructors;
}
public Boolean getAllDeclaredMethods() {
return allDeclaredMethods;
}
public void setAllDeclaredMethods(Boolean allDeclaredMethods) {
this.allDeclaredMethods = allDeclaredMethods;
}
public Boolean getAllDeclaredFields() {
return allDeclaredFields;
}
public void setAllDeclaredFields(Boolean allDeclaredFields) {
this.allDeclaredFields = allDeclaredFields;
}
@JsonAnySetter
public void putAdditionalProperty(String key, Object value) {
additional.put(key, value);
}
@JsonAnyGetter
public Map<String, Object> getAdditional() {
return additional;
}
}
}