diff --git a/docs/src/docs/asciidoc/maven-plugin.adoc b/docs/src/docs/asciidoc/maven-plugin.adoc index 5c55d86ff..303f2caa6 100644 --- a/docs/src/docs/asciidoc/maven-plugin.adoc +++ b/docs/src/docs/asciidoc/maven-plugin.adoc @@ -201,6 +201,43 @@ For example, to build a native image named `myapp` that uses `org.example.ClassN Most of the aforementioned properties can also be set on the command line as a part of Maven invocation. For example, if you want to temporarily enable verbose mode, you can append `-Dverbose` to your Maven command. ==== +[[maven-site-build-report]] +== Maven Site Build Report + +If your native build already emits a GraalVM Native Image build report, you can publish it as a page in the Maven-generated site. +Add the plugin to the `` section: + +[source,xml, role="multi-language-sample"] +---- + + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + + + + build-report + + + + + + +---- + +The report goal copies the generated Native Image build report HTML into the Maven site output and also copies `dynamic-access-metadata.json` when it is present. + +To make sure the source build report exists before `site` runs, enable one of the GraalVM build report switches in the native build configuration and run the native build before site generation: + +[source,bash, role="multi-language-sample"] +---- +./mvnw -Pnative package site +---- + +Supported Native Image build report switches are `--emit build-report` and `-H:+BuildReport`. + [[native-image-tracing-agent]] == Native Image Tracing Agent diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26349c755..3b99c8e57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,8 +7,10 @@ metadataRepository = "1.0-M1" spock = "2.1-groovy-3.0" maven = "3.9.9" mavenAnnotations = "3.6.4" +mavenReporting = "4.0.0" mavenEmbedder = "3.9.9" mavenResolver = "1.9.22" +doxia = "2.1.0" graalvm = "23.0.2" openjson = "1.0.13" junitPlatform = "1.13.0" @@ -47,6 +49,8 @@ maven-pluginApi = { module = "org.apache.maven:maven-plugin-api", version.ref = maven-pluginAnnotations = { module = "org.apache.maven.plugin-tools:maven-plugin-annotations", version.ref = "mavenAnnotations" } maven-core = { module = "org.apache.maven:maven-core", version.ref = "maven" } maven-artifact = { module = "org.apache.maven:maven-artifact", version.ref = "maven" } +maven-reporting-api = { module = "org.apache.maven.reporting:maven-reporting-api", version.ref = "mavenReporting" } +maven-doxia-sink-api = { module = "org.apache.maven.doxia:doxia-sink-api", version.ref = "doxia" } maven-compat = { module = "org.apache.maven:maven-compat", version.ref = "maven" } maven-embedder = { module = "org.apache.maven:maven-embedder", version.ref = "mavenEmbedder" } maven-resolver-basic = { module = "org.apache.maven.resolver:maven-resolver-connector-basic", version.ref = "mavenResolver"} diff --git a/native-maven-plugin/build.gradle.kts b/native-maven-plugin/build.gradle.kts index 5ede3534d..a1b63d5b7 100644 --- a/native-maven-plugin/build.gradle.kts +++ b/native-maven-plugin/build.gradle.kts @@ -71,6 +71,8 @@ dependencies { compileOnly(libs.maven.pluginApi) compileOnly(libs.maven.core) compileOnly(libs.maven.artifact) + compileOnly(libs.maven.reporting.api) + compileOnly(libs.maven.doxia.sink.api) compileOnly(libs.maven.pluginAnnotations) mavenEmbedder(libs.maven.embedder) @@ -83,6 +85,8 @@ dependencies { testImplementation(libs.test.spock) testImplementation(libs.maven.core) testImplementation(libs.maven.artifact) + testImplementation(libs.maven.reporting.api) + testImplementation(libs.maven.doxia.sink.api) testImplementation(libs.jetty.server) testFixturesImplementation(libs.test.spock) @@ -94,6 +98,8 @@ dependencies { functionalTestCommonRepository("org.graalvm.internal:library-with-reflection") functionalTestImplementation(libs.test.spock) + functionalTestImplementation(libs.maven.reporting.api) + functionalTestImplementation(libs.maven.doxia.sink.api) functionalTestRuntimeOnly(libs.slf4j.simple) } diff --git a/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/JavaApplicationFunctionalTest.groovy b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/JavaApplicationFunctionalTest.groovy index 88b943bf2..53581de4a 100644 --- a/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/JavaApplicationFunctionalTest.groovy +++ b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/JavaApplicationFunctionalTest.groovy @@ -143,4 +143,73 @@ class JavaApplicationFunctionalTest extends AbstractGraalVMMavenFunctionalTest { outputContains "Args file written to: target" + File.separator + "native-image" } + def "can publish the native build report into the Maven site"() { + withSample("java-application") + configureNativeBuildSiteReport() + file("target/assets").mkdirs() + file("target/example-app-build-report.html").text = ''' + + + Build report + + + + +

Native Image Build Report

+

Reachability summary

+ + +''' + file("target/assets/report.css").text = "body { background: #fff; }" + file("target/assets/report.js").text = "console.log('build-report');" + file("target/dynamic-access-metadata.json").text = '{"libraries":[]}' + + when: + mvn 'site' + + then: + buildSucceeded + file("target/site/native-build-report/index.html").text.contains("Native Image Build Report") + file("target/site/native-build-report/assets/report.css").exists() + file("target/site/native-build-report/assets/report.js").exists() + file("target/site/native-build-report/dynamic-access-metadata.json").exists() + } + + def "can render a helpful fallback page when the native build report is missing"() { + withSample("java-application") + configureNativeBuildSiteReport() + + when: + mvn 'site' + + then: + buildSucceeded + file("target/site/native-build-report/index.html").text.contains("No GraalVM Native Image build report was found") + file("target/site/native-build-report/index.html").text.contains("--emit build-report") + file("target/site/native-build-report/index.html").text.contains("-H:+BuildReport") + } + + private void configureNativeBuildSiteReport() { + def pom = file("pom.xml") + pom.text = pom.text.replace("", ''' + + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + + + + build-report + + + + + + + +''') + } + } diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeBuildReportMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeBuildReportMojo.java new file mode 100644 index 000000000..fba9708d1 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeBuildReportMojo.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2026, 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven; + +import org.apache.maven.doxia.sink.Sink; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +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 org.apache.maven.reporting.MavenReport; +import org.apache.maven.reporting.MavenReportException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Copies the GraalVM Native Image build report into the Maven site output. + */ +@Mojo(name = "build-report", defaultPhase = LifecyclePhase.SITE, threadSafe = true) +public class NativeBuildReportMojo extends AbstractMojo implements MavenReport { + private static final Pattern ASSET_REFERENCE_PATTERN = Pattern.compile("(?i)(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']"); + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Parameter(property = "native.build.report.skip", defaultValue = "false") + private boolean skip; + + @Parameter(property = "native.build.report.name", defaultValue = "Native Image Build Report") + private String name; + + @Parameter(property = "native.build.report.description", defaultValue = "Copies the GraalVM Native Image build report into the Maven site.") + private String description; + + @Parameter(property = "native.build.report.outputDirectory", defaultValue = "native-build-report") + private String siteReportDirectory; + + @Parameter(property = "outputDir", defaultValue = "${project.build.directory}", required = true) + private File buildOutputDirectory; + + @Parameter(property = "imageName", defaultValue = "${project.artifactId}") + private String imageName; + + @Parameter(property = "native.build.report.file") + private File buildReportFile; + + @Parameter(property = "native.build.report.dynamicAccessMetadataFile", defaultValue = "${project.build.directory}/dynamic-access-metadata.json") + private File dynamicAccessMetadataFile; + + private File reportOutputDirectory; + + @Override + public void execute() throws MojoExecutionException { + try { + generate(null, Locale.getDefault()); + } catch (MavenReportException e) { + throw new MojoExecutionException("Unable to generate native build report", e); + } + } + + @Override + public void generate(Sink sink, Locale locale) throws MavenReportException { + File resolvedReportOutputDirectory = reportOutputDirectory != null + ? reportOutputDirectory + : new File(project.getBuild().getDirectory(), "site"); + Path siteDirectory = new File(resolvedReportOutputDirectory, siteReportDirectory).toPath(); + try { + Files.createDirectories(siteDirectory); + Optional sourceReport = resolveBuildReport(); + if (sourceReport.isPresent()) { + copyReportBundle(sourceReport.get(), siteDirectory); + } else { + writeMissingReportPage(siteDirectory.resolve("index.html")); + } + } catch (IOException e) { + throw new MavenReportException("Unable to generate native build report page", e); + } + } + + @Override + public String getOutputName() { + return siteReportDirectory + File.separator + "index"; + } + + @Override + public String getCategoryName() { + return CATEGORY_PROJECT_REPORTS; + } + + @Override + public String getName(Locale locale) { + return name; + } + + @Override + public String getDescription(Locale locale) { + return description; + } + + @Override + public void setReportOutputDirectory(File directory) { + this.reportOutputDirectory = directory; + } + + @Override + public File getReportOutputDirectory() { + return reportOutputDirectory; + } + + @Override + public boolean isExternalReport() { + return true; + } + + @Override + public boolean canGenerateReport() { + return !skip; + } + + Optional resolveBuildReport() { + if (buildReportFile != null) { + Path configuredReport = buildReportFile.toPath(); + if (Files.isRegularFile(configuredReport)) { + return Optional.of(configuredReport); + } + getLog().warn("Configured build report file does not exist: " + configuredReport); + } + return findBuildReport(buildOutputDirectory.toPath(), imageName); + } + + static Optional findBuildReport(Path buildDirectory, String imageName) { + if (!Files.isDirectory(buildDirectory)) { + return Optional.empty(); + } + try (Stream stream = Files.list(buildDirectory)) { + return stream + .filter(Files::isRegularFile) + .filter(NativeBuildReportMojo::isHtmlFile) + .max(Comparator.comparingInt(path -> buildReportScore(path, imageName)) + .thenComparing(path -> path.getFileName().toString())); + } catch (IOException e) { + return Optional.empty(); + } + } + + static int buildReportScore(Path candidate, String imageName) { + String fileName = candidate.getFileName().toString().toLowerCase(Locale.ROOT); + String normalizedImageName = imageName == null ? "" : imageName.toLowerCase(Locale.ROOT); + int score = 0; + if (!normalizedImageName.isEmpty() && (fileName.equals(normalizedImageName + ".html") || fileName.equals(normalizedImageName + ".htm"))) { + score += 400; + } + if (fileName.contains("build-report")) { + score += 300; + } + if (!normalizedImageName.isEmpty() && fileName.contains(normalizedImageName)) { + score += 200; + } + if (fileName.contains("native")) { + score += 100; + } + return score; + } + + private void copyReportBundle(Path sourceReport, Path siteDirectory) throws IOException { + Path sourceParent = sourceReport.getParent(); + copyFile(sourceReport, siteDirectory.resolve(sourceReport.getFileName())); + copyFile(sourceReport, siteDirectory.resolve("index.html")); + if (sourceParent != null) { + for (Path asset : findReferencedAssets(sourceReport)) { + try { + Path target = relativizeAgainst(sourceParent, asset, siteDirectory); + copyPath(asset, target); + } catch (IOException ex) { + getLog().warn("Unable to copy build report asset " + asset + ": " + ex.getMessage()); + } + } + } + Path dynamicAccessMetadata = dynamicAccessMetadataFile.toPath(); + if (Files.isRegularFile(dynamicAccessMetadata)) { + copyFile(dynamicAccessMetadata, siteDirectory.resolve(dynamicAccessMetadata.getFileName())); + } + } + + static Set findReferencedAssets(Path sourceReport) throws IOException { + Set assets = new LinkedHashSet<>(); + String html = Files.readString(sourceReport, StandardCharsets.UTF_8); + Matcher matcher = ASSET_REFERENCE_PATTERN.matcher(html); + Path baseDirectory = sourceReport.getParent(); + while (matcher.find()) { + String reference = sanitizeReference(matcher.group(1)); + if (reference == null || baseDirectory == null) { + continue; + } + Path asset = baseDirectory.resolve(reference).normalize(); + if (Files.exists(asset) && !asset.equals(sourceReport)) { + assets.add(asset); + } + } + return assets; + } + + private static String sanitizeReference(String reference) { + if (reference == null || reference.isBlank()) { + return null; + } + int fragmentSeparator = reference.indexOf('#'); + if (fragmentSeparator >= 0) { + reference = reference.substring(0, fragmentSeparator); + } + int querySeparator = reference.indexOf('?'); + if (querySeparator >= 0) { + reference = reference.substring(0, querySeparator); + } + if (reference.isBlank() || reference.startsWith("#") || reference.startsWith("/") || reference.startsWith("data:") || reference.startsWith("http:") || reference.startsWith("https:") || reference.startsWith("mailto:") || reference.startsWith("javascript:")) { + return null; + } + return reference; + } + + private static boolean isHtmlFile(Path path) { + String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + return fileName.endsWith(".html") || fileName.endsWith(".htm"); + } + + private static Path relativizeAgainst(Path sourceParent, Path source, Path destinationRoot) { + if (source.startsWith(sourceParent)) { + return destinationRoot.resolve(sourceParent.relativize(source).toString()); + } + return destinationRoot.resolve(source.getFileName().toString()); + } + + private static void copyPath(Path source, Path destination) throws IOException { + if (Files.isDirectory(source)) { + try (Stream stream = Files.walk(source)) { + for (Path entry : (Iterable) stream::iterator) { + Path target = destination.resolve(source.relativize(entry).toString()); + if (Files.isDirectory(entry)) { + Files.createDirectories(target); + } else { + copyFile(entry, target); + } + } + } + } else { + copyFile(source, destination); + } + } + + private static void copyFile(Path source, Path destination) throws IOException { + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + + private void writeMissingReportPage(Path indexFile) throws IOException { + String configuredReportFile = buildReportFile == null ? "auto-detect" : buildReportFile.getAbsolutePath(); + String html = "\n" + + "\n" + + "\n" + + " \n" + + " Native Image Build Report\n" + + " \n" + + "\n" + + "\n" + + "

Native Image Build Report

\n" + + "

No GraalVM Native Image build report was found for this module.

\n" + + "

The site report looks for an existing HTML build report in " + escapeHtml(buildOutputDirectory.getAbsolutePath()) + "" + + " or at the explicitly configured file " + escapeHtml(configuredReportFile) + ".

\n" + + "

To generate one before running Maven Site, enable either --emit build-report or -H:+BuildReport in the native image build arguments, then run the native build phase before site.

\n" + + "
mvn -Pnative package site
\n" + + "\n" + + "\n"; + Files.writeString(indexFile, html, StandardCharsets.UTF_8); + } + + private static String escapeHtml(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} + diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java index a9baff345..bcc0745a9 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java @@ -128,7 +128,7 @@ public void execute() throws MojoExecutionException { * Mojo fails */ private void generateDynamicAccessMetadataIfNeeded(List buildArgs) throws MojoExecutionException { - if (buildArgs.stream().anyMatch(arg -> arg.startsWith("--emit build-report"))) { + if (buildArgs.stream().anyMatch(arg -> arg.startsWith("--emit build-report") || "-H:+BuildReport".equals(arg))) { MojoExecutor.executeMojo( MojoExecutor.plugin( MojoExecutor.groupId(project.getPlugin("org.graalvm.buildtools:native-maven-plugin").getGroupId()), diff --git a/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/NativeBuildReportMojoTest.groovy b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/NativeBuildReportMojoTest.groovy new file mode 100644 index 000000000..cb197011c --- /dev/null +++ b/native-maven-plugin/src/test/groovy/org/graalvm/buildtools/maven/NativeBuildReportMojoTest.groovy @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven + +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path + +class NativeBuildReportMojoTest extends Specification { + @TempDir + Path testDirectory + + void "it prefers the most likely build report file"() { + given: + Files.writeString(testDirectory.resolve("plain.html"), "") + Files.writeString(testDirectory.resolve("example-app-build-report.html"), "") + Files.writeString(testDirectory.resolve("example-app.html"), "") + + when: + def report = NativeBuildReportMojo.findBuildReport(testDirectory, "example-app") + + then: + report.present + report.get().fileName.toString() == "example-app.html" + } + + void "it extracts relative asset references from the build report html"() { + given: + def assetsDir = Files.createDirectories(testDirectory.resolve("assets")) + def css = Files.writeString(assetsDir.resolve("style.css"), "body{}") + def js = Files.writeString(assetsDir.resolve("app.js"), "console.log('ok')") + def report = Files.writeString(testDirectory.resolve("example-app-build-report.html"), ''' + + + + + + + + + + +''') + + when: + def assets = NativeBuildReportMojo.findReferencedAssets(report) + + then: + assets == [css, js] as Set + } +} +