MavenInvoker.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.maven.cling.invoker.mvn;

import java.io.FileNotFoundException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.maven.InternalErrorException;
import org.apache.maven.Maven;
import org.apache.maven.api.Constants;
import org.apache.maven.api.MonotonicClock;
import org.apache.maven.api.annotations.Nullable;
import org.apache.maven.api.cli.InvokerRequest;
import org.apache.maven.api.cli.Logger;
import org.apache.maven.api.cli.mvn.MavenOptions;
import org.apache.maven.api.services.BuilderProblem;
import org.apache.maven.api.services.Lookup;
import org.apache.maven.api.services.Sources;
import org.apache.maven.api.services.ToolchainsBuilder;
import org.apache.maven.api.services.ToolchainsBuilderRequest;
import org.apache.maven.api.services.ToolchainsBuilderResult;
import org.apache.maven.api.services.model.ModelProcessor;
import org.apache.maven.api.toolchain.PersistedToolchains;
import org.apache.maven.cling.event.ExecutionEventLogger;
import org.apache.maven.cling.invoker.CliUtils;
import org.apache.maven.cling.invoker.LookupContext;
import org.apache.maven.cling.invoker.LookupInvoker;
import org.apache.maven.cling.transfer.ConsoleMavenTransferListener;
import org.apache.maven.cling.transfer.QuietMavenTransferListener;
import org.apache.maven.cling.transfer.SimplexTransferListener;
import org.apache.maven.cling.transfer.Slf4jMavenTransferListener;
import org.apache.maven.exception.DefaultExceptionHandler;
import org.apache.maven.exception.ExceptionHandler;
import org.apache.maven.exception.ExceptionSummary;
import org.apache.maven.execution.DefaultMavenExecutionRequest;
import org.apache.maven.execution.ExecutionListener;
import org.apache.maven.execution.MavenExecutionRequest;
import org.apache.maven.execution.MavenExecutionResult;
import org.apache.maven.execution.ProfileActivation;
import org.apache.maven.execution.ProjectActivation;
import org.apache.maven.jline.MessageUtils;
import org.apache.maven.lifecycle.LifecycleExecutionException;
import org.apache.maven.logging.LoggingExecutionListener;
import org.apache.maven.logging.MavenTransferListener;
import org.apache.maven.project.MavenProject;
import org.apache.maven.toolchain.model.ToolchainModel;
import org.eclipse.aether.DefaultRepositoryCache;
import org.eclipse.aether.transfer.TransferListener;

import static java.util.Comparator.comparing;

/**
 * The Maven invoker, that expects whole Maven on classpath and invokes it.
 */
public class MavenInvoker extends LookupInvoker<MavenContext> {
    public MavenInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
        super(protoLookup, contextConsumer);
    }

    @Override
    protected MavenContext createContext(InvokerRequest invokerRequest) {
        return new MavenContext(
                invokerRequest, true, (MavenOptions) invokerRequest.options().orElse(null));
    }

    @Override
    protected int execute(MavenContext context) throws Exception {
        MavenExecutionRequest request = prepareMavenExecutionRequest();
        toolchains(context, request);
        populateRequest(context, context.lookup, request);
        return doExecute(context, request);
    }

    protected MavenExecutionRequest prepareMavenExecutionRequest() throws Exception {
        // explicitly fill in "defaults"?
        DefaultMavenExecutionRequest mavenExecutionRequest = new DefaultMavenExecutionRequest();
        mavenExecutionRequest.setRepositoryCache(new DefaultRepositoryCache());
        mavenExecutionRequest.setInteractiveMode(true);
        mavenExecutionRequest.setCacheTransferError(false);
        mavenExecutionRequest.setIgnoreInvalidArtifactDescriptor(true);
        mavenExecutionRequest.setIgnoreMissingArtifactDescriptor(true);
        mavenExecutionRequest.setRecursive(true);
        mavenExecutionRequest.setReactorFailureBehavior(MavenExecutionRequest.REACTOR_FAIL_FAST);
        mavenExecutionRequest.setStartInstant(MonotonicClock.now());
        mavenExecutionRequest.setLoggingLevel(MavenExecutionRequest.LOGGING_LEVEL_INFO);
        mavenExecutionRequest.setDegreeOfConcurrency(1);
        mavenExecutionRequest.setBuilderId("singlethreaded");
        return mavenExecutionRequest;
    }

    @Override
    protected void lookup(MavenContext context) throws Exception {
        if (context.maven == null) {
            super.lookup(context);
            context.maven = context.lookup.lookup(Maven.class);
        }
    }

    @Override
    protected void postCommands(MavenContext context) throws Exception {
        super.postCommands(context);

        Logger logger = context.logger;
        if (context.options().relaxedChecksums().orElse(false)) {
            logger.info("Disabling strict checksum verification on all artifact downloads.");
        } else if (context.options().strictChecksums().orElse(false)) {
            logger.info("Enabling strict checksum verification on all artifact downloads.");
        }
    }

    protected void toolchains(MavenContext context, MavenExecutionRequest request) throws Exception {
        Path userToolchainsFile = null;
        if (context.options().altUserToolchains().isPresent()) {
            userToolchainsFile =
                    context.cwd.resolve(context.options().altUserToolchains().get());

            if (!Files.isRegularFile(userToolchainsFile)) {
                throw new FileNotFoundException(
                        "The specified user toolchains file does not exist: " + userToolchainsFile);
            }
        } else {
            String userToolchainsFileStr =
                    context.protoSession.getEffectiveProperties().get(Constants.MAVEN_USER_TOOLCHAINS);
            if (userToolchainsFileStr != null) {
                userToolchainsFile = context.cwd.resolve(userToolchainsFileStr);
            }
        }

        Path installationToolchainsFile = null;
        if (context.options().altInstallationToolchains().isPresent()) {
            installationToolchainsFile = context.cwd.resolve(
                    context.options().altInstallationToolchains().get());

            if (!Files.isRegularFile(installationToolchainsFile)) {
                throw new FileNotFoundException(
                        "The specified installation toolchains file does not exist: " + installationToolchainsFile);
            }
        } else {
            String installationToolchainsFileStr =
                    context.protoSession.getEffectiveProperties().get(Constants.MAVEN_INSTALLATION_TOOLCHAINS);
            if (installationToolchainsFileStr != null) {
                installationToolchainsFile = context.installationDirectory
                        .resolve(installationToolchainsFileStr)
                        .normalize();
            }
        }

        request.setInstallationToolchainsFile(
                installationToolchainsFile != null ? installationToolchainsFile.toFile() : null);
        request.setUserToolchainsFile(userToolchainsFile != null ? userToolchainsFile.toFile() : null);

        ToolchainsBuilderRequest toolchainsRequest = ToolchainsBuilderRequest.builder()
                .session(context.protoSession)
                .installationToolchainsSource(
                        installationToolchainsFile != null && Files.isRegularFile(installationToolchainsFile)
                                ? Sources.fromPath(installationToolchainsFile)
                                : null)
                .userToolchainsSource(
                        userToolchainsFile != null && Files.isRegularFile(userToolchainsFile)
                                ? Sources.fromPath(userToolchainsFile)
                                : null)
                .build();

        context.eventSpyDispatcher.onEvent(toolchainsRequest);

        context.logger.debug("Reading installation toolchains from '" + installationToolchainsFile + "'");
        context.logger.debug("Reading user toolchains from '" + userToolchainsFile + "'");

        ToolchainsBuilderResult toolchainsResult =
                context.lookup.lookup(ToolchainsBuilder.class).build(toolchainsRequest);

        context.eventSpyDispatcher.onEvent(toolchainsResult);

        context.effectiveToolchains = toolchainsResult.getEffectiveToolchains();

        if (toolchainsResult.getProblems().hasWarningProblems()) {
            int totalProblems = toolchainsResult.getProblems().totalProblemsReported();
            context.logger.info("");
            context.logger.info(String.format(
                    "%s %s encountered while building the effective toolchains (use -e to see details)",
                    totalProblems, (totalProblems == 1) ? "problem was" : "problems were"));

            if (context.options().showErrors().orElse(false)) {
                for (BuilderProblem problem :
                        toolchainsResult.getProblems().problems().toList()) {
                    context.logger.warn(problem.getMessage() + " @ " + problem.getLocation());
                }
            }

            context.logger.info("");
        }
    }

    @Override
    protected void populateRequest(MavenContext context, Lookup lookup, MavenExecutionRequest request)
            throws Exception {
        super.populateRequest(context, lookup, request);
        if (context.invokerRequest.rootDirectory().isEmpty()) {
            // maven requires this to be set; so default it (and see below at POM)
            request.setMultiModuleProjectDirectory(
                    context.invokerRequest.topDirectory().toFile());
            request.setRootDirectory(context.invokerRequest.topDirectory());
        }

        request.setToolchains(
                Optional.ofNullable(context.effectiveToolchains).map(PersistedToolchains::getToolchains).stream()
                        .flatMap(List::stream)
                        .map(ToolchainModel::new)
                        .collect(Collectors.groupingBy(ToolchainModel::getType)));

        request.setNoSnapshotUpdates(context.options().suppressSnapshotUpdates().orElse(false));
        request.setGoals(context.options().goals().orElse(List.of()));
        request.setReactorFailureBehavior(determineReactorFailureBehaviour(context));
        request.setRecursive(!context.options().nonRecursive().orElse(!request.isRecursive()));
        request.setOffline(context.options().offline().orElse(request.isOffline()));
        request.setUpdateSnapshots(context.options().updateSnapshots().orElse(false));
        request.setGlobalChecksumPolicy(determineGlobalChecksumPolicy(context));

        Path pom = determinePom(context, lookup);
        if (pom != null) {
            request.setPom(pom.toFile());
            if (pom.getParent() != null) {
                request.setBaseDirectory(pom.getParent().toFile());
            }

            // project present, but we could not determine rootDirectory: extra work needed
            if (context.invokerRequest.rootDirectory().isEmpty()) {
                Path rootDirectory = CliUtils.findMandatoryRoot(context.invokerRequest.topDirectory());
                request.setMultiModuleProjectDirectory(rootDirectory.toFile());
                request.setRootDirectory(rootDirectory);
            }
        }

        request.setTransferListener(determineTransferListener(
                context, context.options().noTransferProgress().orElse(false)));
        request.setExecutionListener(determineExecutionListener(context));

        request.setResumeFrom(context.options().resumeFrom().orElse(null));
        request.setResume(context.options().resume().orElse(false));
        request.setMakeBehavior(determineMakeBehavior(context));
        request.setCacheNotFound(context.options().cacheArtifactNotFound().orElse(true));
        request.setCacheTransferError(false);

        if (context.options().strictArtifactDescriptorPolicy().orElse(false)) {
            request.setIgnoreMissingArtifactDescriptor(false);
            request.setIgnoreInvalidArtifactDescriptor(false);
        } else {
            request.setIgnoreMissingArtifactDescriptor(true);
            request.setIgnoreInvalidArtifactDescriptor(true);
        }

        request.setIgnoreTransitiveRepositories(
                context.options().ignoreTransitiveRepositories().orElse(false));

        performProjectActivation(context, request.getProjectActivation());
        performProfileActivation(context, request.getProfileActivation());

        //
        // Builder, concurrency and parallelism
        //
        // We preserve the existing methods for builder selection which is to look for various inputs in the threading
        // configuration. We don't have an easy way to allow a pluggable builder to provide its own configuration
        // parameters but this is sufficient for now. Ultimately we want components like Builders to provide a way to
        // extend the command line to accept its own configuration parameters.
        //
        if (context.options().threads().isPresent()) {
            int degreeOfConcurrency =
                    calculateDegreeOfConcurrency(context.options().threads().get());
            if (degreeOfConcurrency > 1) {
                request.setBuilderId("multithreaded");
                request.setDegreeOfConcurrency(degreeOfConcurrency);
            }
        }

        //
        // Allow the builder to be overridden by the user if requested. The builders are now pluggable.
        //
        if (context.options().builder().isPresent()) {
            request.setBuilderId(context.options().builder().get());
        }
    }

    protected Path determinePom(MavenContext context, Lookup lookup) {
        Path current = context.cwd.get();
        if (context.options().alternatePomFile().isPresent()) {
            current = context.cwd.resolve(context.options().alternatePomFile().get());
        }
        ModelProcessor modelProcessor =
                lookup.lookupOptional(ModelProcessor.class).orElse(null);
        if (modelProcessor != null) {
            return modelProcessor.locateExistingPom(current);
        } else {
            return Files.isRegularFile(current) ? current : null;
        }
    }

    protected String determineReactorFailureBehaviour(MavenContext context) {
        if (context.options().failFast().isPresent()) {
            return MavenExecutionRequest.REACTOR_FAIL_FAST;
        } else if (context.options().failAtEnd().isPresent()) {
            return MavenExecutionRequest.REACTOR_FAIL_AT_END;
        } else if (context.options().failNever().isPresent()) {
            return MavenExecutionRequest.REACTOR_FAIL_NEVER;
        } else {
            return MavenExecutionRequest.REACTOR_FAIL_FAST;
        }
    }

    protected String determineGlobalChecksumPolicy(MavenContext context) {
        if (context.options().strictChecksums().orElse(false)) {
            return MavenExecutionRequest.CHECKSUM_POLICY_FAIL;
        } else if (context.options().relaxedChecksums().orElse(false)) {
            return MavenExecutionRequest.CHECKSUM_POLICY_WARN;
        } else {
            return null;
        }
    }

    protected ExecutionListener determineExecutionListener(MavenContext context) {
        ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory());
        if (context.eventSpyDispatcher != null) {
            listener = context.eventSpyDispatcher.chainListener(listener);
        }
        return new LoggingExecutionListener(listener, determineBuildEventListener(context));
    }

    protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) {
        boolean quiet = context.options().quiet().orElse(false);
        boolean logFile = context.options().logFile().isPresent();
        boolean quietCI = context.invokerRequest.ciInfo().isPresent()
                && !context.options().forceInteractive().orElse(false);

        TransferListener delegate;
        if (quiet || noTransferProgress || quietCI) {
            delegate = new QuietMavenTransferListener();
        } else if (context.interactive && !logFile) {
            if (context.simplexTransferListener == null) {
                SimplexTransferListener simplex = new SimplexTransferListener(new ConsoleMavenTransferListener(
                        context.invokerRequest.messageBuilderFactory(),
                        context.terminal.writer(),
                        context.invokerRequest.effectiveVerbose()));
                context.closeables.add(simplex);
                context.simplexTransferListener = simplex;
            }
            delegate = context.simplexTransferListener;
        } else {
            delegate = new Slf4jMavenTransferListener();
        }
        return new MavenTransferListener(delegate, determineBuildEventListener(context));
    }

    protected String determineMakeBehavior(MavenContext context) {
        if (context.options().alsoMake().isPresent()
                && context.options().alsoMakeDependents().isEmpty()) {
            return MavenExecutionRequest.REACTOR_MAKE_UPSTREAM;
        } else if (context.options().alsoMake().isEmpty()
                && context.options().alsoMakeDependents().isPresent()) {
            return MavenExecutionRequest.REACTOR_MAKE_DOWNSTREAM;
        } else if (context.options().alsoMake().isPresent()
                && context.options().alsoMakeDependents().isPresent()) {
            return MavenExecutionRequest.REACTOR_MAKE_BOTH;
        } else {
            return null;
        }
    }

    protected void performProjectActivation(MavenContext context, ProjectActivation projectActivation) {
        if (context.options().projects().isPresent()
                && !context.options().projects().get().isEmpty()) {
            List<String> optionValues = context.options().projects().get();
            for (final String optionValue : optionValues) {
                for (String token : optionValue.split(",")) {
                    String selector = token.trim();
                    boolean active = true;
                    if (!selector.isEmpty()) {
                        if (selector.charAt(0) == '-' || selector.charAt(0) == '!') {
                            active = false;
                            selector = selector.substring(1);
                        } else if (token.charAt(0) == '+') {
                            selector = selector.substring(1);
                        }
                    }
                    boolean optional = false;
                    if (!selector.isEmpty() && selector.charAt(0) == '?') {
                        optional = true;
                        selector = selector.substring(1);
                    }
                    projectActivation.addProjectActivation(selector, active, optional);
                }
            }
        }
    }

    protected void performProfileActivation(MavenContext context, ProfileActivation profileActivation) {
        if (context.options().activatedProfiles().isPresent()
                && !context.options().activatedProfiles().get().isEmpty()) {
            List<String> optionValues = context.options().activatedProfiles().get();
            for (final String optionValue : optionValues) {
                for (String token : optionValue.split(",")) {
                    String profileId = token.trim();
                    boolean active = true;
                    if (!profileId.isEmpty()) {
                        if (profileId.charAt(0) == '-' || profileId.charAt(0) == '!') {
                            active = false;
                            profileId = profileId.substring(1);
                        } else if (token.charAt(0) == '+') {
                            profileId = profileId.substring(1);
                        }
                    }
                    boolean optional = false;
                    if (!profileId.isEmpty() && profileId.charAt(0) == '?') {
                        optional = true;
                        profileId = profileId.substring(1);
                    }
                    profileActivation.addProfileActivation(profileId, active, optional);
                }
            }
        }
    }

    protected int doExecute(MavenContext context, MavenExecutionRequest request) throws Exception {
        context.eventSpyDispatcher.onEvent(request);

        MavenExecutionResult result;
        try {
            result = context.maven.execute(request);
            context.eventSpyDispatcher.onEvent(result);
        } finally {
            context.eventSpyDispatcher.close();
        }

        if (result.hasExceptions()) {
            ExceptionHandler handler = new DefaultExceptionHandler();
            Map<String, String> references = new LinkedHashMap<>();
            List<MavenProject> failedProjects = new ArrayList<>();

            for (Throwable exception : result.getExceptions()) {
                ExceptionSummary summary = handler.handleException(exception);
                logSummary(context, summary, references, "");

                if (exception instanceof LifecycleExecutionException lifecycleExecutionException) {
                    failedProjects.add(lifecycleExecutionException.getProject());
                }
            }

            context.logger.error("");

            if (!context.options().showErrors().orElse(false)) {
                context.logger.error("To see the full stack trace of the errors, re-run Maven with the '"
                        + MessageUtils.builder().strong("-e") + "' switch");
            }
            if (!context.invokerRequest.effectiveVerbose()) {
                context.logger.error("Re-run Maven using the '"
                        + MessageUtils.builder().strong("-X") + "' switch to enable verbose output");
            }

            if (!references.isEmpty()) {
                context.logger.error("");
                context.logger.error("For more information about the errors and possible solutions"
                        + ", please read the following articles:");

                for (Map.Entry<String, String> entry : references.entrySet()) {
                    context.logger.error(MessageUtils.builder().strong(entry.getValue()) + " " + entry.getKey());
                }
            }

            if (result.canResume()) {
                logBuildResumeHint(context, "mvn [args] -r");
            } else if (!failedProjects.isEmpty()) {
                List<MavenProject> sortedProjects = result.getTopologicallySortedProjects();

                // Sort the failedProjects list in the topologically sorted order.
                failedProjects.sort(comparing(sortedProjects::indexOf));

                MavenProject firstFailedProject = failedProjects.get(0);
                if (!firstFailedProject.equals(sortedProjects.get(0))) {
                    String resumeFromSelector = getResumeFromSelector(sortedProjects, firstFailedProject);
                    logBuildResumeHint(context, "mvn [args] -rf " + resumeFromSelector);
                }
            }

            if (context.options().failNever().orElse(false)) {
                context.logger.info("Build failures were ignored.");
                return 0;
            } else {
                return 1;
            }
        } else {
            return 0;
        }
    }

    protected void logBuildResumeHint(MavenContext context, String resumeBuildHint) {
        context.logger.error("");
        context.logger.error("After correcting the problems, you can resume the build with the command");
        context.logger.error(
                MessageUtils.builder().a("  ").strong(resumeBuildHint).toString());
    }

    /**
     * A helper method to determine the value to resume the build with {@code -rf} taking into account the edge case
     *   where multiple modules in the reactor have the same artifactId.
     * <p>
     * {@code -rf :artifactId} will pick up the first module which matches, but when multiple modules in the reactor
     *   have the same artifactId, effective failed module might be later in build reactor.
     * This means that developer will either have to type groupId or wait for build execution of all modules which
     *   were fine, but they are still before one which reported errors.
     * <p>Then the returned value is {@code groupId:artifactId} when there is a name clash and
     * {@code :artifactId} if there is no conflict.
     * This method is made package-private for testing purposes.
     *
     * @param mavenProjects Maven projects which are part of build execution.
     * @param firstFailedProject The first project which has failed.
     * @return Value for -rf flag to resume build exactly from place where it failed ({@code :artifactId} in general
     * and {@code groupId:artifactId} when there is a name clash).
     */
    protected String getResumeFromSelector(List<MavenProject> mavenProjects, MavenProject firstFailedProject) {
        boolean hasOverlappingArtifactId = mavenProjects.stream()
                        .filter(project -> firstFailedProject.getArtifactId().equals(project.getArtifactId()))
                        .count()
                > 1;

        if (hasOverlappingArtifactId) {
            return firstFailedProject.getGroupId() + ":" + firstFailedProject.getArtifactId();
        }

        return ":" + firstFailedProject.getArtifactId();
    }

    protected static final Pattern NEXT_LINE = Pattern.compile("\r?\n");

    protected static final Pattern LAST_ANSI_SEQUENCE = Pattern.compile("(\u001B\\[[;\\d]*[ -/]*[@-~])[^\u001B]*$");

    protected static final String ANSI_RESET = "\u001B\u005Bm";

    protected void logSummary(
            MavenContext context, ExceptionSummary summary, Map<String, String> references, String indent) {
        String referenceKey = "";

        if (summary.getReference() != null && !summary.getReference().isEmpty()) {
            referenceKey =
                    references.computeIfAbsent(summary.getReference(), k -> "[Help " + (references.size() + 1) + "]");
        }

        String msg = summary.getMessage();

        if (!referenceKey.isEmpty()) {
            if (msg.indexOf('\n') < 0) {
                msg += " -> " + MessageUtils.builder().strong(referenceKey);
            } else {
                msg += "\n-> " + MessageUtils.builder().strong(referenceKey);
            }
        }

        String[] lines = NEXT_LINE.split(msg);
        String currentColor = "";

        for (int i = 0; i < lines.length; i++) {
            // add eventual current color inherited from previous line
            String line = currentColor + lines[i];

            // look for last ANSI escape sequence to check if nextColor
            Matcher matcher = LAST_ANSI_SEQUENCE.matcher(line);
            String nextColor = "";
            if (matcher.find()) {
                nextColor = matcher.group(1);
                if (ANSI_RESET.equals(nextColor)) {
                    // last ANSI escape code is reset: no next color
                    nextColor = "";
                }
            }

            // effective line, with indent and reset if end is colored
            line = indent + line + ("".equals(nextColor) ? "" : ANSI_RESET);

            if ((i == lines.length - 1)
                    && (context.options().showErrors().orElse(false)
                            || (summary.getException() instanceof InternalErrorException))) {
                context.logger.error(line, summary.getException());
            } else {
                context.logger.error(line);
            }

            currentColor = nextColor;
        }

        indent += "  ";

        for (ExceptionSummary child : summary.getChildren()) {
            logSummary(context, child, references, indent);
        }
    }
}