From a31f8738d83567066ca778ece0b27c29b52a2493 Mon Sep 17 00:00:00 2001 From: admrene Date: Fri, 24 Apr 2026 09:23:59 +0200 Subject: [PATCH] Add initial implementation of Quarkus Reflection Import Plugin --- README.md | 73 ++++ pom.xml | 68 ++++ .../reflection/ReflectionImportMojo.java | 368 ++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/de/rsitservice/quarkus/reflection/ReflectionImportMojo.java diff --git a/README.md b/README.md new file mode 100644 index 0000000..c96ec5a --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Quarkus Reflection Import Plugin + +Maven-Plugin, das Java-Imports in deinem Projekt scannt und passende Eintraege in `reflection-config.json` schreibt. + +## Ziel + +Das Plugin durchsucht Java-Dateien nach `import`-Statements. Wenn ein Import mit einem in der POM konfigurierten Paketpraefix matcht, wird der importierte Typ zur Reflection-Konfiguration hinzugefuegt. + +## Build + +```bash +mvn clean install +``` + +## Verwendung im Quarkus-Projekt + +```xml + + + + de.rsitservice + quarkus-reflection-import-plugin + 1.0.0-SNAPSHOT + + + generate-reflection-config + + generate-reflection-config + + + + + + com.example.dto + org.acme.model + + + + + + + + + + + +``` + +## Standardverhalten + +- Scan-Roots: `${project.compileSourceRoots}` +- Import-Filter: konfiguriert über `` +- Ignoriert `static import` +- Ziel-Datei: `target/classes/META-INF/native-image///reflection-config.json` +- Vorhandene Konfigurationsdateien werden gemerged (wenn vorhanden): + - Ziel-Datei selbst + - `src/main/resources/META-INF/native-image///reflection-config.json` + - `src/main/resources/META-INF/native-image/reflection-config.json` + +## Wichtige Parameter + +- `importPackages` (required) +- `sourceDirectories` (optional) +- `reflectionConfigFile` (optional) +- `additionalReflectionConfigInputs` (optional) +- `allDeclaredConstructors` (default: `true`) +- `allDeclaredMethods` (default: `true`) +- `allDeclaredFields` (default: `true`) +- `skip` (default: `false`) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bfe7470 --- /dev/null +++ b/pom.xml @@ -0,0 +1,68 @@ + + 4.0.0 + + de.rsitservice + quarkus-reflection-import-plugin + 1.0.0-SNAPSHOT + maven-plugin + + Quarkus Reflection Import Plugin + Scans configured Java imports and writes them to reflection-config.json for Quarkus/GraalVM native builds. + + + 11 + UTF-8 + 3.15.1 + 3.9.9 + 2.20.0 + + + + + org.apache.maven + maven-plugin-api + ${maven.api.version} + + + org.apache.maven + maven-core + ${maven.api.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.tools.version} + provided + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${maven.plugin.tools.version} + + quarkus-reflection + + + + default-descriptor + + descriptor + helpmojo + + + + + + + diff --git a/src/main/java/de/rsitservice/quarkus/reflection/ReflectionImportMojo.java b/src/main/java/de/rsitservice/quarkus/reflection/ReflectionImportMojo.java new file mode 100644 index 0000000..d823804 --- /dev/null +++ b/src/main/java/de/rsitservice/quarkus/reflection/ReflectionImportMojo.java @@ -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> 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 sourceDirectories; + + /** Package prefixes to match against discovered imports (required). */ + @Parameter(property = "importPackages", required = true) + private List 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 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 normalizedPrefixes = normalizeImportPrefixes(importPackages); + if (normalizedPrefixes.isEmpty()) { + throw new MojoFailureException("Parameter 'importPackages' is required and must not be empty."); + } + + File targetFile = resolveTargetFile(); + Set importsFromCode = scanImports(normalizedPrefixes); + + Map 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 normalizeImportPrefixes(List 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 scanImports(List normalizedPrefixes) throws MojoExecutionException { + List roots = resolveSourceRoots(); + Set matchedImports = new TreeSet<>(); + + for (Path root : roots) { + if (!Files.isDirectory(root)) { + getLog().debug("Skipping non-existing source directory: " + root); + continue; + } + + try (Stream 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 resolveSourceRoots() { + List configured = sourceDirectories == null ? List.of() : sourceDirectories; + List 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 normalizedPrefixes, + Set matchedImports) { + List 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 normalizedPrefixes) { + for (String prefix : normalizedPrefixes) { + if (importedType.equals(prefix) || importedType.startsWith(prefix + ".")) { + return true; + } + } + return false; + } + + private Map loadExistingEntries(File targetFile) throws MojoExecutionException { + Map merged = new LinkedHashMap<>(); + Set 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 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 files, File file) { + if (file != null && file.isFile()) { + files.add(file.getAbsoluteFile()); + } + } + + private List 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 entries) throws MojoExecutionException { + File parent = targetFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new MojoExecutionException("Cannot create output directory: " + parent.getAbsolutePath()); + } + + List 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 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 getAdditional() { + return additional; + } + } +} +