/*
* Copyright 2015 Lukas Krejci
*
* Licensed 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.revapi.maven;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.function.Supplier;
import org.apache.maven.doxia.sink.Sink;
import org.apache.maven.doxia.siterenderer.Renderer;
import org.apache.maven.plugins.annotations.Component;
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.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
import org.apache.maven.reporting.AbstractMavenReport;
import org.apache.maven.reporting.MavenReportException;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.revapi.API;
import org.revapi.Archive;
import org.revapi.CompatibilityType;
import org.revapi.DifferenceSeverity;
import org.revapi.Element;
import org.revapi.Revapi;
/**
* @author Lukas Krejci
* @since 0.1
*/
@Mojo(name = "report", defaultPhase = LifecyclePhase.SITE,
requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME)
public class ReportMojo extends AbstractMavenReport {
/**
* The JSON configuration of various analysis options. The available options depend on what
* analyzers are present on the plugins classpath through the {@code <dependencies>}.
*
* <p>These settings take precedence over the configuration loaded from {@code analysisConfigurationFiles}.
*/
@Parameter(property = Props.analysisConfiguration.NAME, defaultValue = Props.analysisConfiguration.DEFAULT_VALUE)
protected String analysisConfiguration;
/**
* The list of files containing the configuration of various analysis options.
* The available options depend on what analyzers are present on the plugins classpath through the
* {@code <dependencies>}.
*
* <p>The {@code analysisConfiguration} can override the settings present in the files.
*
* <p>The list is either a list of strings or has the following form:
* <pre><code>
* <analysisConfigurationFiles>
* <configurationFile>
* <path>path/to/the/file/relative/to/project/base/dir</path>
* <roots>
* <root>configuration/root1</root>
* <root>configuration/root2</root>
* ...
* </roots>
* </configurationFile>
* ...
* </analysisConfigurationFiles>
* </code></pre>
*
* where
* <ul>
* <li>{@code path} is mandatory,</li>
* <li>{@code roots} is optional and specifies the subtrees of the JSON config that should be used for
* configuration. If not specified, the whole file is taken into account.</li>
* </ul>
* The {@code configuration/root1} and {@code configuration/root2} are JSON paths to the roots of the
* configuration inside that JSON config file. This might be used in cases where multiple configurations are stored
* within a single file and you want to use a particular one.
*
* <p>An example of this might be a config file which contains API changes to be ignored in all past versions of a
* library. The classes to be ignored are specified in a configuration that is specific for each version:
* <pre><code>
* {
* "0.1.0" : {
* "revapi" : {
* "ignore" : [
* {
* "code" : "java.method.addedToInterface",
* "new" : "method void com.example.MyInterface::newMethod()",
* "justification" : "This interface is not supposed to be implemented by clients."
* },
* ...
* ]
* }
* },
* "0.2.0" : {
* ...
* }
* }
* </code></pre>
*/
@Parameter(property = Props.analysisConfigurationFiles.NAME, defaultValue = Props.analysisConfigurationFiles.DEFAULT_VALUE)
protected Object[] analysisConfigurationFiles;
/**
* Set to false if you want to tolerate files referenced in the {@code analysisConfigurationFiles} missing on the
* filesystem and therefore not contributing to the analysis configuration.
*
* <p>The default is {@code true}, which means that a missing analysis configuration file will fail the build.
*/
@Parameter(property = Props.failOnMissingConfigurationFiles.NAME, defaultValue = Props.failOnMissingConfigurationFiles.DEFAULT_VALUE)
protected boolean failOnMissingConfigurationFiles;
/**
* The coordinates of the old artifacts. Defaults to single artifact with the latest released version of the
* current project.
* <p/>
* If the this property is null, the {@link #oldVersion} property is checked for a value of the old version of the
* artifact being built.
*
* @see #oldVersion
*/
@Parameter(property = Props.oldArtifacts.NAME, defaultValue = Props.oldArtifacts.DEFAULT_VALUE)
protected String[] oldArtifacts;
/**
* If you don't want to compare a different artifact than the one being built, specifying the just the old version
* is simpler way of specifying the old artifact.
* <p/>
* The default value is "RELEASE" meaning that the old version is the last released version of the artifact being
* built.
*/
@Parameter(property = Props.oldVersion.NAME, defaultValue = Props.oldVersion.DEFAULT_VALUE)
protected String oldVersion;
/**
* The coordinates of the new artifacts. These are the full GAVs of the artifacts, which means that you can compare
* different artifacts than the one being built. If you merely want to specify the artifact being built, use
* {@link #newVersion} property instead.
*/
@Parameter(property = Props.newArtifacts.NAME, defaultValue = Props.newArtifacts.DEFAULT_VALUE)
protected String[] newArtifacts;
/**
* The new version of the artifact. Defaults to "${project.version}".
*/
@Parameter(property = Props.newVersion.NAME, defaultValue = Props.newVersion.DEFAULT_VALUE)
protected String newVersion;
/**
* Problems with this or higher severity will be included in the report.
* Possible values: equivalent, nonBreaking, potentiallyBreaking, breaking.
*/
@Parameter(property = Props.reportSeverity.NAME, defaultValue = Props.reportSeverity.DEFAULT_VALUE)
protected FailSeverity reportSeverity;
/**
* Whether to skip the mojo execution.
*/
@Parameter(property = Props.skip.NAME, defaultValue = Props.skip.DEFAULT_VALUE)
protected boolean skip;
@Parameter(property = "revapi.outputDirectory", defaultValue = "${project.reporting.outputDirectory}",
required = true, readonly = true)
protected String outputDirectory;
/**
* Whether to include the dependencies in the API checks. This is the default thing to do because your API might
* be exposing classes from the dependencies and thus classes from your dependencies could become part of your API.
* <p>
* However, setting this to false might be useful in situations where you have checked your dependencies in another
* module and don't want do that again. In that case, you might want to configure Revapi to ignore missing classes
* because it might find the classes from your dependencies as used in your API and would complain that it could not
* find it. See <a href="http://revapi.org/modules/revapi-java/extensions/java.html">the docs</a>.
*/
@Parameter(property = Props.checkDependencies.NAME, defaultValue = Props.checkDependencies.DEFAULT_VALUE)
protected boolean checkDependencies;
/**
* If set, this property demands a format of the version string when the {@link #oldVersion} or {@link #newVersion}
* parameters are set to {@code RELEASE} or {@code LATEST} special version strings.
* <p>
* Because Maven will report the newest non-snapshot version as the latest release, we might end up comparing a
* {@code .Beta} or other pre-release versions with the new version. This might not be what you want and setting the
* versionFormat will make sure that a newest version conforming to the version format is used instead of the one
* resolved by Maven by default.
* <p>
* This parameter is a regular expression pattern that the version string needs to match in order to be considered
* a {@code RELEASE}.
*/
@Parameter(property = Props.versionFormat.NAME, defaultValue = Props.versionFormat.DEFAULT_VALUE)
protected String versionFormat;
@Component
protected Renderer siteRenderer;
@Component
protected RepositorySystem repositorySystem;
@Parameter(defaultValue = "${repositorySystemSession}", readonly = true)
protected RepositorySystemSession repositorySystemSession;
/**
* If true (the default) revapi will always download the information about the latest version from the remote
* repositories (instead of using locally cached info). This will respect the offline settings.
*/
@Parameter(property = Props.alwaysCheckForReleaseVersion.NAME, defaultValue = Props.alwaysCheckForReleaseVersion.DEFAULT_VALUE)
protected boolean alwaysCheckForReleaseVersion;
/**
* If true, the build will fail if one of the old or new artifacts fails to be resolved. Defaults to false.
*/
@Parameter(property = Props.failOnUnresolvedArtifacts.NAME, defaultValue = Props.failOnUnresolvedArtifacts.DEFAULT_VALUE)
protected boolean failOnUnresolvedArtifacts;
/**
* If true, the build will fail if some of the dependencies of the old or new artifacts fail to be resolved.
* Defaults to false.
*/
@Parameter(property = Props.failOnUnresolvedDependencies.NAME, defaultValue = Props.failOnUnresolvedDependencies.DEFAULT_VALUE)
protected boolean failOnUnresolvedDependencies;
/**
* Set this to false if you want to use the goal to generate other kind of output than the default report for the
* Maven-generated site. You can generate such output by using different reporting extensions (like
* revapi-reporter-text).
*/
@Parameter(property = Props.generateSiteReport.NAME, defaultValue = Props.generateSiteReport.DEFAULT_VALUE)
protected boolean generateSiteReport;
/**
* A comma-separated list of extensions (fully-qualified class names thereof) that are not taken into account during
* API analysis. By default, all extensions that are found on the classpath are used.
* <p>
* You can modify this set if you use another extensions that change the found differences in a way that the
* determined new version would not correspond to what it should be.
*/
@Parameter(property = Props.disallowedExtensions.NAME, defaultValue = Props.disallowedExtensions.DEFAULT_VALUE)
protected String disallowedExtensions;
private API oldAPI;
private API newAPI;
private ReportTimeReporter reporter;
@Override
protected Renderer getSiteRenderer() {
return siteRenderer;
}
@Override
protected String getOutputDirectory() {
return outputDirectory;
}
@Override
protected MavenProject getProject() {
return project;
}
@Override public boolean canGenerateReport() {
return project.getArtifact().getArtifactHandler().isAddedToClasspath();
}
@Override
protected void executeReport(Locale locale) throws MavenReportException {
ensureAnalyzed(locale);
if (skip) {
return;
}
if (oldAPI == null || newAPI == null) {
getLog().warn("Could not determine the artifacts to compare. If you're comparing the" +
" currently built version, have you run the package goal?");
return;
}
if (generateSiteReport) {
Sink sink = getSink();
ResourceBundle bundle = getBundle(locale);
startReport(sink, bundle);
reportBody(reporter, oldAPI, newAPI, sink, bundle);
endReport(sink);
}
}
protected void startReport(Sink sink, ResourceBundle messages) {
sink.head();
sink.title();
sink.text(messages.getString("report.revapi.title"));
sink.title_();
sink.head_();
sink.body();
sink.section1();
sink.sectionTitle1();
sink.rawText(messages.getString("report.revapi.title"));
sink.sectionTitle1_();
}
protected void endReport(Sink sink) {
sink.section1_();
sink.body_();
}
protected void reportBody(ReportTimeReporter reporterWithResults, API oldAPI, API newAPI, Sink sink,
ResourceBundle messages) {
sink.paragraph();
sink.text(getDescription(messages, oldAPI, newAPI));
sink.paragraph_();
reportDifferences(reporterWithResults.reportsBySeverity.get(DifferenceSeverity.BREAKING), sink, messages,
"report.revapi.changes.breaking");
reportDifferences(reporterWithResults.reportsBySeverity.get(DifferenceSeverity.POTENTIALLY_BREAKING), sink,
messages, "report.revapi.changes.potentiallyBreaking");
reportDifferences(reporterWithResults.reportsBySeverity.get(DifferenceSeverity.NON_BREAKING), sink, messages,
"report.revapi.changes.nonBreaking");
}
@Override
public String getOutputName() {
return "revapi-report";
}
@Override
public String getName(Locale locale) {
return getBundle(locale).getString("report.revapi.name");
}
@Override
public String getDescription(Locale locale) {
ensureAnalyzed(locale);
if (oldAPI == null || newAPI == null) {
getLog().debug("Was unable to determine the old and new artifacts to compare while determining" +
" the report description.");
return null;
} else {
return getDescription(getBundle(locale), oldAPI, newAPI);
}
}
private String getDescription(ResourceBundle messages, API oldAPI, API newAPI) {
String message = messages.getString("report.revapi.description");
return MessageFormat.format(message, niceList(oldAPI.getArchives()), niceList(newAPI.getArchives()));
}
protected Analyzer prepareAnalyzer(Locale locale) {
if (generateSiteReport) {
reporter = new ReportTimeReporter(reportSeverity.asDifferenceSeverity());
}
//noinspection Duplicates
if (oldArtifacts == null || oldArtifacts.length == 0) {
//bail out quickly for POM artifacts (or any other packaging without a file result) - there's nothing we can
//analyze there
//only do it here, because oldArtifacts might point to another artifact.
//if we end up here in this branch, we know we'll be comparing the current artifact with something.
if (!project.getArtifact().getArtifactHandler().isAddedToClasspath()) {
skip = true;
return null;
}
oldArtifacts = new String[]{
Analyzer.getProjectArtifactCoordinates(project, oldVersion)};
}
//noinspection Duplicates
if (newArtifacts == null || newArtifacts.length == 0) {
if (!project.getArtifact().getArtifactHandler().isAddedToClasspath()) {
skip = true;
return null;
}
newArtifacts = new String[]{
Analyzer.getProjectArtifactCoordinates(project, newVersion)};
}
final List<String> disallowedExtensions = this.disallowedExtensions == null
? Collections.emptyList()
: Arrays.asList(this.disallowedExtensions.split("\\s*,\\s*"));
Supplier<Revapi.Builder> ctor =
AbstractRevapiMojo.getDisallowedExtensionsAwareRevapiConstructor(disallowedExtensions);
return new Analyzer(analysisConfiguration, analysisConfigurationFiles, oldArtifacts,
newArtifacts, project, repositorySystem, repositorySystemSession, reporter, locale, getLog(),
failOnMissingConfigurationFiles, failOnUnresolvedArtifacts, failOnUnresolvedDependencies,
alwaysCheckForReleaseVersion, checkDependencies, versionFormat, ctor);
}
private void ensureAnalyzed(Locale locale) {
if (!skip && reporter == null) {
if (generateSiteReport) {
reporter = new ReportTimeReporter(reportSeverity.asDifferenceSeverity());
}
if (newArtifacts != null && newArtifacts.length == 1 && "BUILD".equals(newArtifacts[0])) {
getLog().warn("\"BUILD\" coordinates are deprecated. Just leave \"newArtifacts\" undefined and" +
" specify \"${project.version}\" as the value for \"newVersion\" (which is the default, so" +
" you don't actually have to do that either).");
oldArtifacts = null;
}
//noinspection Duplicates
if (oldArtifacts == null || oldArtifacts.length == 0) {
//bail out quickly for POM artifacts (or any other packaging without a file result) - there's nothing we can
//analyze there
//only do it here, because oldArtifacts might point to another artifact.
//if we end up here in this branch, we know we'll be comparing the current artifact with something.
if (!project.getArtifact().getArtifactHandler().isAddedToClasspath()) {
skip = true;
return;
}
oldArtifacts = new String[]{
Analyzer.getProjectArtifactCoordinates(project, oldVersion)};
}
//noinspection Duplicates
if (newArtifacts == null || newArtifacts.length == 0) {
if (!project.getArtifact().getArtifactHandler().isAddedToClasspath()) {
skip = true;
return;
}
newArtifacts = new String[]{
Analyzer.getProjectArtifactCoordinates(project, newVersion)};
}
try (Analyzer analyzer = prepareAnalyzer(locale)) {
if (analyzer == null) {
return;
}
analyzer.analyze();
oldAPI = analyzer.getResolvedOldApi();
newAPI = analyzer.getResolvedNewApi();
} catch (Exception e) {
throw new IllegalStateException("Failed to generate report.", e);
}
}
}
protected ResourceBundle getBundle(Locale locale) {
return ResourceBundle.getBundle("revapi-report", locale, this.getClass().getClassLoader());
}
protected String niceList(Iterable<? extends Archive> archives) {
StringBuilder bld = new StringBuilder();
Iterator<? extends Archive> it = archives.iterator();
if (it.hasNext()) {
bld.append(it.next().getName());
} else {
return "";
}
while (it.hasNext()) {
bld.append(", ").append(it.next().getName());
}
return bld.toString();
}
private void reportDifferences(
EnumMap<CompatibilityType, List<ReportTimeReporter.DifferenceReport>> diffsPerType, Sink sink,
ResourceBundle bundle, String typeKey) {
if (diffsPerType == null || diffsPerType.isEmpty()) {
return;
}
sink.section2();
sink.sectionTitle2();
sink.text(bundle.getString(typeKey));
sink.sectionTitle2_();
reportDifferences(diffsPerType.get(CompatibilityType.BINARY), sink, bundle,
"report.revapi.compatibilityType.binary");
reportDifferences(diffsPerType.get(CompatibilityType.SOURCE), sink, bundle,
"report.revapi.compatibilityType.source");
reportDifferences(diffsPerType.get(CompatibilityType.SEMANTIC), sink, bundle,
"report.revapi.compatibilityType.semantic");
reportDifferences(diffsPerType.get(CompatibilityType.OTHER), sink, bundle,
"report.revapi.compatibilityType.other");
sink.section2_();
}
private void reportDifferences(List<ReportTimeReporter.DifferenceReport> diffs, Sink sink, ResourceBundle bundle,
String typeKey) {
if (diffs == null || diffs.isEmpty()) {
return;
}
sink.section3();
sink.sectionTitle3();
sink.text(bundle.getString(typeKey));
sink.sectionTitle3_();
sink.table();
sink.tableRow();
sink.tableHeaderCell();
sink.text(bundle.getString("report.revapi.difference.code"));
sink.tableHeaderCell_();
sink.tableHeaderCell();
sink.text(bundle.getString("report.revapi.difference.element"));
sink.tableHeaderCell_();
sink.tableHeaderCell();
sink.text(bundle.getString("report.revapi.difference.description"));
sink.tableHeaderCell_();
sink.tableRow_();
diffs.sort((d1, d2) -> {
String c1 = d1.difference.code;
String c2 = d2.difference.code;
int cmp = c1.compareTo(c2);
if (cmp != 0) {
return cmp;
}
Element e1 = d1.newElement == null ? d1.oldElement : d1.newElement;
Element e2 = d2.newElement == null ? d2.oldElement : d2.newElement;
cmp = e1.getClass().getName().compareTo(e2.getClass().getName());
if (cmp != 0) {
return cmp;
}
return e1.getFullHumanReadableString().compareTo(e2.getFullHumanReadableString());
});
for (ReportTimeReporter.DifferenceReport d : diffs) {
String element = d.oldElement == null ? (d.newElement.getFullHumanReadableString()) :
d.oldElement.getFullHumanReadableString();
sink.tableRow();
sink.tableCell();
sink.monospaced();
sink.text(d.difference.code);
sink.monospaced_();
sink.tableCell_();
sink.tableCell();
sink.monospaced();
sink.bold();
sink.text(element);
sink.bold_();
sink.monospaced_();
sink.tableCell();
sink.text(d.difference.description);
sink.tableCell_();
sink.tableRow_();
}
sink.table_();
sink.section3_();
}
}