InlineMojo.java

/*
 * 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.basepom.inline.mojo;

import static com.google.common.base.Preconditions.checkState;
import static java.lang.String.format;
import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

import org.basepom.inline.transformer.ClassPath;
import org.basepom.inline.transformer.ClassPathResource;
import org.basepom.inline.transformer.ClassPathTag;
import org.basepom.inline.transformer.JarTransformer;
import org.basepom.inline.transformer.TransformerException;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Collectors;
import javax.xml.stream.XMLStreamException;

import com.google.common.base.Functions;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import com.google.common.io.CharStreams;
import org.apache.maven.archiver.MavenArchiver;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
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.DependencyResolutionException;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
import org.apache.maven.project.ProjectBuilder;
import org.apache.maven.project.ProjectBuildingException;
import org.apache.maven.project.ProjectDependenciesResolver;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.util.artifact.JavaScopes;
import org.jdom2.JDOMException;

/**
 * Inlines one or more dependencies of the project, relocated the classes and writes a new artifact.
 */
@Mojo(name = "inline", defaultPhase = LifecyclePhase.PACKAGE,
        requiresProject = true, threadSafe = true,
        requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
        requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME)
public final class InlineMojo extends AbstractMojo {

    private static final PluginLog LOG = new PluginLog(InlineMojo.class);

    private static final Predicate<Dependency> EXCLUDE_SYSTEM_SCOPE = dependency -> !JavaScopes.SYSTEM.equals(dependency.getScope());
    private static final Predicate<Dependency> EXCLUDE_PROVIDED_SCOPE = dependency -> !JavaScopes.PROVIDED.equals(dependency.getScope());


    @Parameter(defaultValue = "${project}", readonly = true, required = true)
    private MavenProject project;

    @Parameter(defaultValue = "${session}", readonly = true, required = true)
    private MavenSession mavenSession;

    @Parameter(defaultValue = "${reactorProjects}", readonly = true, required = true)
    private List<MavenProject> reactorProjects;

    @Component
    private ProjectBuilder mavenProjectBuilder;

    @Component
    private ProjectDependenciesResolver projectDependenciesResolver;

    @Component
    private RepositorySystem repositorySystem;

    @Component
    private MavenProjectHelper projectHelper;

    /**
     * The destination directory for the inlined artifact.
     */
    @Parameter(defaultValue = "${project.build.directory}")
    private File outputDirectory;

    /**
     * Timestamp for reproducible output archive entries, either formatted as ISO 8601
     * <code>yyyy-MM-dd'T'HH:mm:ssXXX</code> or as an int representing seconds since the epoch (like
     * <a href="https://reproducible-builds.org/docs/source-date-epoch/">SOURCE_DATE_EPOCH</a>).
     */
    @Parameter(defaultValue = "${project.build.outputTimestamp}")
    private String outputTimestamp;

    /**
     * The POM file to use.
     */
    @Parameter(property = "inline.pomFile", defaultValue = "${project.file}")
    private File pomFile;

    /**
     * Direct dependencies to inline. Each dependency here must be listed in the project POM. Any transitive dependency is added to the final jar, unless it is
     * in {@code RUNTIME} scope. {@code RUNTIME} dependencies become a runtime dependency of the resulting final jar <b>unless</b> they are listed here. In that
     * case, they are inlined in the final jar as well.
     */
    @Parameter
    private List<InlineDependency> inlineDependencies = ImmutableList.of();

    // called by maven
    public void setInlineDependencies(List<InlineDependency> inlineDependencies) {
        this.inlineDependencies = ImmutableList.copyOf(inlineDependencies);
    }

    /**
     * Include dependencies. A dependency is given as <tt>groupId:artifactId</tt>. The wildcard character '*' is supported for group id and artifact id.
     * <p>
     * Includes and excludes operate on the list of potential dependencies to inline. They can not be used to add additional dependencies that are not listed in
     * the &lt;inlineDependency&gt; elements.
     */
    @Parameter
    private List<ArtifactIdentifier> includes = ImmutableList.of();

    // called by maven
    public void setIncludes(List<String> includes) {
        this.includes = includes.stream().map(ArtifactIdentifier::new).collect(Collectors.toList());
    }

    /**
     * Exclude dependencies from inclusion. A dependency is given as <tt>groupId:artifactId</tt>. Any transitive dependency that has been pulled in can be
     * excluded here. The wildcard character '*' is supported for group id and artifact id.
     * <p>
     * Includes and excludes operate on the list of potential dependencies to inline. They can not be used to add additional dependencies that are not listed in
     * the &lt;inlineDependency&gt; elements.
     */
    @Parameter
    private List<ArtifactIdentifier> excludes = ImmutableList.of();

    // called by maven
    public void setExcludes(List<String> excludes) {
        this.excludes = excludes.stream().map(ArtifactIdentifier::new).collect(Collectors.toList());
    }

    /**
     * Adds external jar processors. These must be on the dependency path for the plugin. See the "Additional Processors" documentation.
     */
    @Parameter
    private List<String> additionalProcessors = ImmutableList.of();

    // called by maven
    public void setAdditionalProcessors(List<String> processors) {
        this.additionalProcessors = ImmutableList.copyOf(processors);
    }

    /**
     * Hide inlined classes from IDE autocompletion.
     */
    @Parameter(defaultValue = "true", property = "inline.hide-classes")
    private boolean hideClasses;

    /**
     * Skip the execution.
     */
    @Parameter(defaultValue = "false", property = "inline.skip")
    private boolean skip;

    /**
     * Silence all non-output and non-error messages.
     */
    @Parameter(defaultValue = "false", property = "inline.quiet")
    private boolean quiet;

    /**
     * Defines the package prefix for all relocated classes. This prefix must be a valid package name. All relocated classes are put under this prefix.
     */
    @Parameter(required = true, property = "inline.prefix")
    private String prefix;

    /**
     * Fail if an inline dependency is defined but the corresponding dependency is not actually found.
     */
    @Parameter(defaultValue = "true", property = "inline.failOnNoMatch")
    private boolean failOnNoMatch;

    /**
     * Fail if any duplicate exists after processing the contents.
     */
    @Parameter(defaultValue = "true", property = "inline.failOnDuplicate")
    private boolean failOnDuplicate;

    /**
     * The path to the output file for the inlined artifact. When this parameter is set, the created archive will neither replace the project's main artifact
     * nor will it be attached. Hence, this parameter causes the parameters {@link #inlinedArtifactAttached}, {@link #inlinedClassifierName} to be ignored when
     * used.
     */
    @Parameter
    private File outputJarFile;

    /**
     * The path to the output file for the new POM file. When this parameter is set, the created pom file will not replace the project's pom file.
     */
    @Parameter
    private File outputPomFile;


    /**
     * If true, attach the inlined artifact, if false replace the original artifact.
     */
    @Parameter(defaultValue = "false", property = "inline.attachArtifact")
    private boolean inlinedArtifactAttached;

    /**
     * If true, replace the POM file with a new version that has all inlined dependencies removed. It is possible to write a POM file that works to build the
     * jar with inlined dependencies and then use the same POM file for the resulting artifact (by having all dependencies marked as <tt>provided</tt> and
     * ensure that those dependencies do not have additional, transitive dependencies. This tends to be error prone and it is recommended to have the plugin
     * rewrite the POM file.
     */
    @Parameter(defaultValue = "true", property = "inline.replacePomFile")
    private boolean replacePomFile;

    /**
     * The name of the classifier used in case the inlined artifact is attached.
     */
    @Parameter(defaultValue = "inlined")
    private String inlinedClassifierName;


    @Override
    public void execute() throws MojoExecutionException {

        if (this.skip) {
            LOG.report(quiet, "skipping plugin execution");
            return;
        }

        if ("pom".equals(project.getPackaging())) {
            LOG.report(quiet, "ignoring POM project");
            return;
        }

        if (project.getArtifact().getFile() == null) {
            throw new MojoExecutionException("No project artifact found!");
        }

        Instant timestamp = MavenArchiver.parseBuildOutputTimestamp(outputTimestamp).orElseGet(Instant::now);

        try {
            ImmutableSetMultimap.Builder<InlineDependency, Dependency> dependencyBuilder = ImmutableSetMultimap.builder();
            ImmutableSet.Builder<Dependency> pomDependenciesToAdd = ImmutableSet.builder();

            computeDependencyMap(dependencyBuilder, pomDependenciesToAdd);

            ImmutableSetMultimap<InlineDependency, Dependency> dependencyMap = dependencyBuilder.build();

            rewriteJarFile(timestamp.toEpochMilli(), dependencyMap);
            rewritePomFile(pomDependenciesToAdd.build(), ImmutableSet.copyOf(dependencyMap.values()));

        } catch (UncheckedIOException e) {
            throw new MojoExecutionException(e.getCause());
        } catch (TransformerException | IOException | DependencyResolutionException | ProjectBuildingException | XMLStreamException | JDOMException e) {
            throw new MojoExecutionException(e);
        }
    }

    private void computeDependencyMap(
            ImmutableSetMultimap.Builder<InlineDependency, Dependency> dependencyMapBuilder,
            ImmutableSet.Builder<Dependency> pomBuilder)
            throws DependencyResolutionException, ProjectBuildingException {

        DependencyBuilder dependencyBuilder = new DependencyBuilder(project, mavenSession, mavenProjectBuilder, projectDependenciesResolver, reactorProjects);

        ImmutableSet<ArtifactIdentifier> directArtifacts = project.getDependencyArtifacts().stream()
                .map(ArtifactIdentifier::new)
                .collect(ImmutableSet.toImmutableSet());

        ImmutableList<Dependency> directDependencies = dependencyBuilder.mapProject(project,
                (node, parents) -> directArtifacts.contains(new ArtifactIdentifier(node)));

        // build the full set of dependencies with all scopes and everything.
        ImmutableList<Dependency> projectDependencies = dependencyBuilder.mapProject(project,
                ScopeLimitingFilter.computeDependencyScope(ScopeLimitingFilter.COMPILE_PLUS_RUNTIME));

        Map<String, Dependency> idMap = projectDependencies.stream()
                .filter(dependency -> dependency.getArtifact() != null)
                .collect(ImmutableMap.toImmutableMap(InlineMojo::getId, Functions.identity()));

        BiConsumer<InlineDependency, Dependency> dependencyConsumer = (inlineDependency, dependency) -> {
            LOG.debug("%s matches %s for inlining.", inlineDependency, dependency);
            dependencyMapBuilder.put(inlineDependency, dependency);
        };

        ImmutableSet.Builder<Dependency> directExcludes = ImmutableSet.builder();

        // first find all the direct dependencies. Add anything that is not hit to the additional exclude list

        ImmutableSortedSet.Builder<String> directLogBuilder = ImmutableSortedSet.naturalOrder();

        directDependencies.stream()
                // remove anything that does not match the filter set.
                // optionals also need to be matched by the inline dependency below
                .filter(createFilterSet(true))
                .forEach(dependency -> {
                    Optional<InlineDependency> inlineDependency = findInlineDependencyMatch(dependency);
                    if (inlineDependency.isPresent()) {
                        dependencyConsumer.accept(inlineDependency.get(), dependency);
                        directLogBuilder.add(dependency.toString());
                    } else {
                        directExcludes.add(dependency);
                    }
                });

        ImmutableSortedSet<String> directLog = directLogBuilder.build();

        if (!quiet) {
            LOG.info("Inlined dependencies");
            LOG.info("====================");

            for (String dependency : directLog) {
                LOG.info("    %s", dependency);
            }
            LOG.info("");
        }

        Set<ArtifactIdentifier> excludes = directExcludes.build().stream()
                .map(ArtifactIdentifier::new)
                .collect(Collectors.toUnmodifiableSet());

        this.excludes = ImmutableList.copyOf(Iterables.concat(this.excludes, excludes));

        LOG.debug("Excludes after creating includes: %s", this.excludes);

        var directDependencyMap = dependencyMapBuilder.build().asMap();

        ImmutableSortedSet.Builder<String> transitiveLogBuilder = ImmutableSortedSet.naturalOrder();

        for (var dependencyEntry : directDependencyMap.entrySet()) {
            InlineDependency inlineDependency = dependencyEntry.getKey();
            for (Dependency projectDependency : dependencyEntry.getValue()) {

                Consumer<Dependency> consumer;
                if (inlineDependency.isInlineTransitive()) {
                    // transitive deps are added to the jar
                    consumer = dependency -> {
                        Optional<InlineDependency> explicitMatch = findInlineDependencyMatch(dependency);

                        // If the dependency is not a runtime dependency, it is included in the inline jar
                        // Runtime dependencies are only included if they are explicitly listed as an
                        // included dependency. Otherwise, they are added as a runtime dep to the inline jar.
                        if (!JavaScopes.RUNTIME.equals(dependency.getScope()) || explicitMatch.isPresent()) {
                            dependencyConsumer.accept(inlineDependency, dependency);
                            transitiveLogBuilder.add(dependency.toString());
                        } else {
                            pomBuilder.add(dependency);
                        }
                    };
                } else {
                    // non-transitive deps need to be written into the POM.
                    consumer = pomBuilder::add;
                }

                dependencyBuilder.mapDependency(projectDependency, ScopeLimitingFilter.computeTransitiveScope(projectDependency.getScope()))
                        .stream()
                        // replace deps in the transitive set with deps in the root set if present (will
                        // override the scope here with the root scope)
                        .map(dependency -> idMap.getOrDefault(getId(dependency), dependency))
                        // remove system and provided dependencies, keep optionals if allowed
                        .filter(createFilterSet(inlineDependency.isInlineOptionals()))
                        // make sure that the inline dependency actually pulls the dep in.
                        .filter(this::isDependencyIncluded)
                        .forEach(consumer);
            }
        }

        if (!quiet) {
            LOG.info("");
            LOG.info("Transitive dependencies");
            LOG.info("=======================");
            for (String dependency : Sets.difference(transitiveLogBuilder.build(), directLog)) {
                LOG.info("    %s", dependency);
            }
            LOG.info("");
        }
    }

    private Optional<InlineDependency> findInlineDependencyMatch(Dependency dependency) {
        for (InlineDependency inlineDependency : inlineDependencies) {
            if (inlineDependency.matchDependency(dependency)) {
                return Optional.of(inlineDependency);
            }
        }
        return Optional.empty();
    }

    private static String getId(Dependency dependency) {
        Artifact artifact = dependency.getArtifact();
        checkState(artifact != null, "Artifact for dependency %s is null!", dependency);

        return Joiner.on(':').join(artifact.getGroupId(), artifact.getArtifactId(), artifact.getClassifier());
    }

    private Predicate<Dependency> createFilterSet(boolean includeOptional) {

        // filter system scope dependencies. Those are never inlined.
        Predicate<Dependency> predicate = EXCLUDE_SYSTEM_SCOPE;
        predicate = predicate.and(EXCLUDE_PROVIDED_SCOPE);

        if (!includeOptional) {
            predicate = predicate.and(Predicate.not(Dependency::isOptional));
        }
        return predicate;
    }

    public boolean isDependencyIncluded(Dependency dependency) {

        boolean included = this.includes.stream()
                .map(artifactIdentifier -> artifactIdentifier.matchDependency(dependency))
                .findFirst()
                .orElse(this.includes.isEmpty());

        boolean excluded = this.excludes.stream()
                .map(artifactIdentifier -> artifactIdentifier.matchDependency(dependency))
                .findFirst()
                .orElse(false);

        return included && !excluded;
    }


    private void rewriteJarFile(long timestamp, ImmutableSetMultimap<InlineDependency, Dependency> dependencies) throws TransformerException, IOException {
        File outputJar = (this.outputJarFile != null) ? outputJarFile : inlinedArtifactFileWithClassifier();

        doJarTransformation(outputJar, timestamp, dependencies);

        if (this.outputJarFile == null) {
            if (this.inlinedArtifactAttached) {
                LOG.info("Attaching inlined artifact.");
                projectHelper.attachArtifact(project, project.getArtifact().getType(), inlinedClassifierName, outputJar);
            } else {
                LOG.info("Replacing original artifact with inlined artifact.");
                File originalArtifact = project.getArtifact().getFile();

                if (originalArtifact != null) {
                    File backupFile = new File(originalArtifact.getParentFile(), "original-" + originalArtifact.getName());
                    Files.move(originalArtifact.toPath(), backupFile.toPath(), ATOMIC_MOVE, REPLACE_EXISTING);
                    Files.move(outputJar.toPath(), originalArtifact.toPath(), ATOMIC_MOVE, REPLACE_EXISTING);
                }
            }
        }
    }

    private void rewritePomFile(Set<Dependency> dependenciesToAdd, Set<Dependency> dependenciesToRemove) throws IOException, XMLStreamException, JDOMException {
        String pomContents;

        try (BufferedReader reader = Files.newBufferedReader(project.getFile().toPath(), StandardCharsets.UTF_8)) {
            pomContents = CharStreams.toString(reader);
        }

        PomUtil pomUtil = new PomUtil(pomContents);
        dependenciesToRemove.forEach(pomUtil::removeDependency);
        dependenciesToAdd.forEach(pomUtil::addDependency);

        // some rewriters (maven flatten plugin) rewrites the new pom name as a hidden file.
        String pomName = this.pomFile.getName();
        pomName = "new-" + (pomName.startsWith(".") ? pomName.substring(1) : pomName);

        File newPomFile = this.outputPomFile != null ? outputPomFile : new File(this.outputDirectory, pomName);
        try (BufferedWriter writer = Files.newBufferedWriter(newPomFile.toPath(), StandardCharsets.UTF_8)) {
            pomUtil.writePom(writer);
        }

        if (this.replacePomFile) {
            project.setPomFile(newPomFile);
        }
    }

    private void doJarTransformation(File outputJar, long timestamp, ImmutableSetMultimap<InlineDependency, Dependency> dependencies)
            throws TransformerException, IOException {

        try (JarOutputStream jarOutputStream = new JarOutputStream(Files.newOutputStream(outputJar.toPath()))) {
            Consumer<ClassPathResource> jarConsumer = getJarWriter(jarOutputStream);
            JarTransformer transformer = new JarTransformer(jarConsumer, timestamp, true, ImmutableSet.copyOf(additionalProcessors));

            // Build the class path
            ClassPath classPath = new ClassPath(project.getBasedir(), timestamp);
            // maintain the manifest file for the main artifact
            var artifact = project.getArtifact();
            classPath.addFile(artifact.getFile(), artifact.getGroupId(), artifact.getArtifactId(), ClassPathTag.ROOT_JAR);

            dependencies.forEach(
                    (inlineDependency, dependency) -> {
                        var dependencyArtifact = dependency.getArtifact();
                        checkState(dependencyArtifact.getFile() != null, "Could not locate artifact file for %s", dependencyArtifact);
                        classPath.addFile(dependencyArtifact.getFile(), prefix, dependencyArtifact.getGroupId(), dependencyArtifact.getArtifactId(),
                                hideClasses);
                    });

            transformer.transform(classPath);
        }
    }

    private Consumer<ClassPathResource> getJarWriter(JarOutputStream jarOutputStream) {
        return classPathResource -> {
            try {
                String name = classPathResource.getName();
                LOG.debug(format("Writing '%s' to jar", name));
                JarEntry outputEntry = new JarEntry(name);
                outputEntry.setTime(classPathResource.getLastModifiedTime());
                outputEntry.setCompressedSize(-1);
                jarOutputStream.putNextEntry(outputEntry);
                jarOutputStream.write(classPathResource.getContent());
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        };
    }

    private File inlinedArtifactFileWithClassifier() {
        final var artifact = project.getArtifact();
        String inlineName = String.format("%s-%s-%s.%s",
                project.getArtifactId(),
                artifact.getVersion(),
                this.inlinedClassifierName,
                artifact.getArtifactHandler().getExtension());

        return new File(this.outputDirectory, inlineName);
    }
}