/* * 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 static java.util.stream.Collectors.toList; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.Spliterator; import java.util.function.Function; import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.apache.maven.RepositoryUtils; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.logging.Log; import org.apache.maven.project.MavenProject; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositoryException; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.repository.RepositoryPolicy; import org.eclipse.aether.resolution.ArtifactResolutionException; import org.eclipse.aether.resolution.VersionRangeResolutionException; import org.jboss.dmr.ModelNode; import org.revapi.API; import org.revapi.AnalysisContext; import org.revapi.Reporter; import org.revapi.Revapi; import org.revapi.configuration.JSONUtil; import org.revapi.maven.utils.ArtifactResolver; import org.revapi.maven.utils.ScopeDependencySelector; import org.revapi.maven.utils.ScopeDependencyTraverser; /** * @author Lukas Krejci * @since 0.1 */ public final class Analyzer implements AutoCloseable { private static final Pattern ANY_NON_SNAPSHOT = Pattern.compile("^.*(?<!-SNAPSHOT)$"); private final String analysisConfiguration; private final Object[] analysisConfigurationFiles; private final String[] oldGavs; private final String[] newGavs; private final Artifact[] oldArtifacts; private final Artifact[] newArtifacts; private final MavenProject project; private final RepositorySystem repositorySystem; private final RepositorySystemSession repositorySystemSession; private final Reporter reporter; private final Locale locale; private final Log log; private final boolean failOnMissingConfigurationFiles; private final boolean failOnMissingArchives; private final boolean failOnMissingSupportArchives; private final Supplier<Revapi.Builder> revapiConstructor; private final boolean resolveDependencies; private final Pattern versionRegex; private API resolvedOldApi; private API resolvedNewApi; private Revapi revapi; Analyzer(String analysisConfiguration, Object[] analysisConfigurationFiles, String[] oldGavs, String[] newGavs, MavenProject project, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, Reporter reporter, Locale locale, Log log, boolean failOnMissingConfigurationFiles, boolean failOnMissingArchives, boolean failOnMissingSupportArchives, boolean alwaysUpdate, boolean resolveDependencies, String versionRegex) { this(analysisConfiguration, analysisConfigurationFiles, oldGavs, newGavs, project, repositorySystem, repositorySystemSession, reporter, locale, log, failOnMissingConfigurationFiles, failOnMissingArchives, failOnMissingSupportArchives, alwaysUpdate, resolveDependencies, versionRegex, () -> Revapi.builder().withAllExtensionsFromThreadContextClassLoader()); } Analyzer(String analysisConfiguration, Object[] analysisConfigurationFiles, Artifact[] oldArtifacts, Artifact[] newArtifacts, MavenProject project, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, Reporter reporter, Locale locale, Log log, boolean failOnMissingConfigurationFiles, boolean failOnMissingArchives, boolean failOnMissingSupportArchives, boolean alwaysUpdate, boolean resolveDependencies, String versionRegex, Supplier<Revapi.Builder> ctor) { this(analysisConfiguration, analysisConfigurationFiles, oldArtifacts, newArtifacts, null, null, project, repositorySystem, repositorySystemSession, reporter, locale, log, failOnMissingConfigurationFiles, failOnMissingArchives, failOnMissingSupportArchives, alwaysUpdate, resolveDependencies, versionRegex, ctor, null); } Analyzer(String analysisConfiguration, Object[] analysisConfigurationFiles, String[] oldGavs, String[] newGavs, MavenProject project, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, Reporter reporter, Locale locale, Log log, boolean failOnMissingConfigurationFiles, boolean failOnMissingArchives, boolean failOnMissingSupportArchives, boolean alwaysUpdate, boolean resolveDependencies, String versionRegex, Supplier<Revapi.Builder> revapiConstructor) { this(analysisConfiguration, analysisConfigurationFiles, null, null, oldGavs, newGavs, project, repositorySystem, repositorySystemSession, reporter, locale, log, failOnMissingConfigurationFiles, failOnMissingArchives, failOnMissingSupportArchives, alwaysUpdate, resolveDependencies, versionRegex, revapiConstructor, null); } Analyzer(String analysisConfiguration, Object[] analysisConfigurationFiles, Artifact[] oldArtifacts, Artifact[] newArtifacts, MavenProject project, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, Reporter reporter, Locale locale, Log log, boolean failOnMissingConfigurationFiles, boolean failOnMissingArchives, boolean failOnMissingSupportArchives, boolean alwaysUpdate, boolean resolveDependencies, String versionRegex, Revapi sharedRevapi) { this(analysisConfiguration, analysisConfigurationFiles, oldArtifacts, newArtifacts, null, null, project, repositorySystem, repositorySystemSession, reporter, locale, log, failOnMissingConfigurationFiles, failOnMissingArchives, failOnMissingSupportArchives, alwaysUpdate, resolveDependencies, versionRegex, null, sharedRevapi); } Analyzer(String analysisConfiguration, Object[] analysisConfigurationFiles, Artifact[] oldArtifacts, Artifact[] newArtifacts, String[] oldGavs, String[] newGavs, MavenProject project, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, Reporter reporter, Locale locale, Log log, boolean failOnMissingConfigurationFiles, boolean failOnMissingArchives, boolean failOnMissingSupportArchives, boolean alwaysUpdate, boolean resolveDependencies, String versionRegex, Supplier<Revapi.Builder> revapiConstructor, Revapi sharedRevapi) { this.analysisConfiguration = analysisConfiguration; this.analysisConfigurationFiles = analysisConfigurationFiles; this.oldGavs = oldGavs; this.newGavs = newGavs; this.oldArtifacts = oldArtifacts; this.newArtifacts = newArtifacts; this.project = project; this.repositorySystem = repositorySystem; this.resolveDependencies = resolveDependencies; this.versionRegex = versionRegex == null ? null : Pattern.compile(versionRegex); DefaultRepositorySystemSession session = new DefaultRepositorySystemSession(repositorySystemSession); session.setDependencySelector(new ScopeDependencySelector("compile", "provided")); session.setDependencyTraverser(new ScopeDependencyTraverser("compile", "provided")); if (alwaysUpdate) { session.setUpdatePolicy(RepositoryPolicy.UPDATE_POLICY_ALWAYS); } this.repositorySystemSession = session; this.reporter = reporter; this.locale = locale; this.log = log; this.failOnMissingConfigurationFiles = failOnMissingConfigurationFiles; this.failOnMissingArchives = failOnMissingArchives; this.failOnMissingSupportArchives = failOnMissingSupportArchives; this.revapi = sharedRevapi; this.revapiConstructor = revapiConstructor; } public static String getProjectArtifactCoordinates(MavenProject project, String versionOverride) { org.apache.maven.artifact.Artifact artifact = project.getArtifact(); String extension = artifact.getArtifactHandler().getExtension(); String version = versionOverride == null ? project.getVersion() : versionOverride; if (artifact.hasClassifier()) { return project.getGroupId() + ":" + project.getArtifactId() + ":" + extension + ":" + artifact.getClassifier() + ":" + version; } else { return project.getGroupId() + ":" + project.getArtifactId() + ":" + extension + ":" + version; } } void validateConfiguration() throws MojoExecutionException { try (Revapi revapi = Revapi.builder().withAllExtensionsFromThreadContextClassLoader().build()) { AnalysisContext.Builder ctxBuilder = AnalysisContext.builder().withLocale(locale); gatherConfig(ctxBuilder); revapi.validateConfiguration(ctxBuilder.build()); } catch (Exception e) { throw new MojoExecutionException("Failed to validate analysis configuration.", e); } } /** * Resolves the gav using the resolver. If the gav corresponds to the project artifact and is an unresolved version * for a RELEASE or LATEST, the gav is resolved such it a release not newer than the project version is found that * optionally corresponds to the provided version regex, if provided. * * <p>If the gav exactly matches the current project, the file of the artifact is found on the filesystem in * target directory and the resolver is ignored. * * @param project the project to restrict by, if applicable * @param gav the gav to resolve * @param versionRegex the optional regex the version must match to be considered. * @param resolver the version resolver to use * @return the resolved artifact matching the criteria. * * @throws VersionRangeResolutionException on error * @throws ArtifactResolutionException on error */ static Artifact resolveConstrained(MavenProject project, String gav, Pattern versionRegex, ArtifactResolver resolver) throws VersionRangeResolutionException, ArtifactResolutionException { if (gav.endsWith(":RELEASE") || gav.endsWith(":LATEST")) { versionRegex = versionRegex == null ? ANY_NON_SNAPSHOT : versionRegex; Artifact a = new DefaultArtifact(gav); String upTo = project.getGroupId().equals(a.getGroupId()) && project.getArtifactId().equals(a.getArtifactId()) ? project.getVersion() : null; return resolver.resolveNewestMatching(gav, upTo, versionRegex); } else { String projectGav = getProjectArtifactCoordinates(project, null); Artifact ret = null; if (projectGav.equals(gav)) { ret = findProjectArtifact(project); } return ret == null ? resolver.resolveArtifact(gav) : ret; } } private static Artifact findProjectArtifact(MavenProject project) { String extension = project.getArtifact().getArtifactHandler().getExtension(); String fileName = project.getModel().getBuild().getFinalName() + "." + extension; File f = new File(new File(project.getBasedir(), "target"), fileName); if (f.exists()) { Artifact ret = RepositoryUtils.toArtifact(project.getArtifact()); return ret.setFile(f); } else { return null; } } @SuppressWarnings("unchecked") void resolveArtifacts() { if (resolvedOldApi == null) { final ArtifactResolver resolver = new ArtifactResolver(repositorySystem, repositorySystemSession, project.getRemoteProjectRepositories()); Function<String, MavenArchive> toFileArchive = gav -> { try { Artifact a = resolveConstrained(project, gav, versionRegex, resolver); return MavenArchive.of(a); } catch (ArtifactResolutionException | VersionRangeResolutionException | IllegalArgumentException e) { throw new MarkerException(e.getMessage(), e); } }; List<MavenArchive> oldArchives = new ArrayList<>(1); try { if (oldGavs != null) { oldArchives = Stream.of(oldGavs).map(toFileArchive).collect(toList()); } if (oldArtifacts != null) { oldArchives.addAll(Stream.of(oldArtifacts).map(MavenArchive::of).collect(toList())); } } catch (MarkerException | IllegalArgumentException e) { String message = "Failed to resolve old artifacts: " + e.getMessage() + "."; if (failOnMissingArchives) { throw new IllegalStateException(message, e); } else { log.warn(message + " The API analysis will not proceed."); return; } } List<MavenArchive> newArchives = new ArrayList<>(1); try { if (newGavs != null) { newArchives = Stream.of(newGavs).map(toFileArchive).collect(toList()); } if (newArtifacts != null) { newArchives.addAll(Stream.of(newArtifacts).map(MavenArchive::of).collect(toList())); } } catch (MarkerException | IllegalArgumentException e) { String message = "Failed to resolve new artifacts: " + e.getMessage() + "."; if (failOnMissingArchives) { throw new IllegalStateException(message, e); } else { log.warn(message + " The API analysis will not proceed."); return; } } //now we need to be a little bit clever. When using RELEASE or LATEST as the version of the old artifact //it might happen that it gets resolved to the same version as the new artifacts - this notoriously happens //when releasing using the release plugin - you first build your artifacts, put them into the local repo //and then do the site updates for the released version. When you do the site, maven will find the released //version in the repo and resolve RELEASE to it. You compare it against what you just built, i.e. the same //code, et voila, the site report doesn't ever contain any found differences... Set<MavenArchive> oldTransitiveDeps = new HashSet<>(); Set<MavenArchive> newTransitiveDeps = new HashSet<>(); if (resolveDependencies) { oldTransitiveDeps.addAll(collectDeps("old", resolver, oldGavs)); oldTransitiveDeps.addAll(collectDeps("old", resolver, oldArtifacts)); newTransitiveDeps.addAll(collectDeps("new", resolver, newGavs)); newTransitiveDeps.addAll(collectDeps("new", resolver, newArtifacts)); } resolvedOldApi = API.of(oldArchives).supportedBy(oldTransitiveDeps).build(); resolvedNewApi = API.of(newArchives).supportedBy(newTransitiveDeps).build(); } } private Set<MavenArchive> collectDeps(String depDescription, ArtifactResolver resolver, String... gavs) { try { if (gavs == null) { return Collections.emptySet(); } ArtifactResolver.CollectionResult res = resolver.collectTransitiveDeps(gavs); return collectDeps(depDescription, res); } catch (RepositoryException e) { return handleResolutionError(e, depDescription, null); } } private Set<MavenArchive> collectDeps(String depDescription, ArtifactResolver resolver, Artifact... gavs) { try { if (gavs == null) { return Collections.emptySet(); } ArtifactResolver.CollectionResult res = resolver.collectTransitiveDeps(Stream.of(gavs).map(Object::toString) .toArray(String[]::new)); return collectDeps(depDescription, res); } catch (RepositoryException e) { return handleResolutionError(e, depDescription, null); } } @SuppressWarnings("unchecked") private Set<MavenArchive> collectDeps(String depDescription, ArtifactResolver.CollectionResult res) { Set<MavenArchive> ret = null; try { ret = new HashSet<>(); for (Artifact a : res.getResolvedArtifacts()) { try { ret.add(MavenArchive.of(a)); } catch (IllegalArgumentException e) { res.getFailures().add(e); } } if (!res.getFailures().isEmpty()) { StringBuilder bld = new StringBuilder(); for (Exception e : res.getFailures()) { bld.append(e.getMessage()).append(", "); } bld.replace(bld.length() - 2, bld.length(), ""); throw new MarkerException("Resolution of some artifacts failed: " + bld.toString()); } else { return ret; } } catch (MarkerException e) { return handleResolutionError(e, depDescription, ret); } } private Set<MavenArchive> handleResolutionError(Exception e, String depDescription, Set<MavenArchive> toReturn) { String message = "Failed to resolve dependencies of " + depDescription + " artifacts: " + e.getMessage() + "."; if (failOnMissingSupportArchives) { throw new IllegalArgumentException(message, e); } else { if (log.isDebugEnabled()) { log.warn(message + ". The API analysis might produce unexpected results.", e); } else { log.warn(message + ". The API analysis might produce unexpected results."); } return toReturn == null ? Collections.<MavenArchive>emptySet() : toReturn; } } @SuppressWarnings("unchecked") void analyze() throws MojoExecutionException { //This is useful so that users know what RELEASE actually resolved to. Function<MavenArchive, String> extractName = new Function<MavenArchive, String>() { @Override public String apply(MavenArchive mavenArchive) { return mavenArchive.getName(); } }; resolveArtifacts(); if (resolvedOldApi == null || resolvedNewApi == null) { return; } List<?> oldArchives = StreamSupport.stream( (Spliterator<MavenArchive>) resolvedOldApi.getArchives().spliterator(), false) .map(extractName).collect(toList()); List<?> newArchives = StreamSupport.stream( (Spliterator<MavenArchive>) resolvedNewApi.getArchives().spliterator(), false) .map(extractName).collect(toList()); log.info("Comparing " + oldArchives + " against " + newArchives + (resolveDependencies ? " (including their transitive dependencies)." : ".")); try { buildRevapi(); AnalysisContext.Builder ctxBuilder = AnalysisContext.builder().withOldAPI(resolvedOldApi) .withNewAPI(resolvedNewApi).withLocale(locale); gatherConfig(ctxBuilder); revapi.analyze(ctxBuilder.build()); } catch (Exception e) { throw new MojoExecutionException("Failed to analyze archives", e); } } public API getResolvedNewApi() { return resolvedNewApi; } public API getResolvedOldApi() { return resolvedOldApi; } public Revapi getRevapi() { buildRevapi(); return revapi; } /** * Closes Revapi iff it was instantiated by this analyzer (using an implicit or explicit revapi constructor * supplier). If a pre-instantiated Revapi instance was supplied to the constructor of this analyzer, the Revapi * instance is NOT closed by this call. It is the responsibility of the "owner" of that Revapi instance to properly * close it. * * @throws Exception */ @Override public void close() throws Exception { //if revapiConstructor == null, we obtained the revapi instance from outside... The caller is therefore //responsible for closing it. if (revapi != null && revapiConstructor != null) { revapi.close(); } } @SuppressWarnings("unchecked") private void gatherConfig(AnalysisContext.Builder ctxBld) throws MojoExecutionException { if (analysisConfigurationFiles != null && analysisConfigurationFiles.length > 0) { for (Object pathOrConfigFile : analysisConfigurationFiles) { ConfigurationFile configFile; if (pathOrConfigFile instanceof String) { configFile = new ConfigurationFile(); configFile.setPath((String) pathOrConfigFile); } else { configFile = (ConfigurationFile) pathOrConfigFile; } String path = configFile.getPath(); File f = new File(path); if (!f.isAbsolute()) { f = new File(project.getBasedir(), path); } if (!f.isFile() || !f.canRead()) { String message = "Could not locate analysis configuration file '" + f.getAbsolutePath() + "'."; if (failOnMissingConfigurationFiles) { throw new MojoExecutionException(message); } else { log.debug(message); continue; } } try (FileInputStream in = new FileInputStream(f)) { ModelNode config = ModelNode.fromJSONStream(JSONUtil.stripComments(in, Charset.forName("UTF-8"))); String[] roots = configFile.getRoots(); if (roots == null) { ctxBld.mergeConfiguration(config); } else { for (String r : roots) { String[] rootPath = r.split("/"); ModelNode root = config.get(rootPath); if (!root.isDefined()) { continue; } ctxBld.mergeConfiguration(root); } } } catch (IOException e) { throw new MojoExecutionException("Could not load configuration from '" + f.getAbsolutePath() + "': " + e.getMessage()); } } } if (analysisConfiguration != null) { ctxBld.mergeConfigurationFromJSON(analysisConfiguration); } } private void buildRevapi() { if (revapi == null) { Revapi.Builder builder = revapiConstructor.get(); if (reporter != null) { builder.withReporters(reporter); } revapi = builder.build(); } } private static class MarkerException extends RuntimeException { public MarkerException(String message) { super(message); } public MarkerException(String message, Throwable cause) { super(message, cause); } } }