ValidateInvoker.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.mvnval;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Stream;
import org.apache.maven.api.Constants;
import org.apache.maven.api.RemoteRepository;
import org.apache.maven.api.Session;
import org.apache.maven.api.annotations.Nullable;
import org.apache.maven.api.cli.InvokerException;
import org.apache.maven.api.cli.InvokerRequest;
import org.apache.maven.api.cli.mvnval.ValidateOptions;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Provides;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Profile;
import org.apache.maven.api.model.Repository;
import org.apache.maven.api.model.RepositoryPolicy;
import org.apache.maven.api.services.Lookup;
import org.apache.maven.api.services.ModelBuilder;
import org.apache.maven.api.services.ModelBuilder.ModelBuilderSession;
import org.apache.maven.api.services.ModelBuilderException;
import org.apache.maven.api.services.ModelBuilderRequest;
import org.apache.maven.api.services.ModelBuilderRequest.RequestType;
import org.apache.maven.api.services.ModelBuilderResult;
import org.apache.maven.api.services.RepositoryFactory;
import org.apache.maven.api.services.SettingsBuilder;
import org.apache.maven.api.services.Sources;
import org.apache.maven.cling.invoker.LookupContext;
import org.apache.maven.cling.invoker.LookupInvoker;
import org.apache.maven.cling.logging.Slf4jConfiguration;
import org.apache.maven.impl.InternalSession;
import org.apache.maven.impl.standalone.ApiRunner;
import org.apache.maven.jline.MessageUtils;
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.spi.connector.transport.TransporterFactory;
import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
import org.eclipse.aether.spi.io.PathProcessor;
import org.eclipse.aether.transport.apache.ApacheTransporterFactory;
import org.eclipse.aether.transport.file.FileTransporterFactory;
import org.jline.reader.UserInterruptException;
import org.jline.terminal.Terminal;
/**
* Validates POM files without building them.
* <p>
* Two modes, and one contains the other. {@code raw} stops at
* {@link ModelBuilder.ModelBuilderSession#validate}, reaching no repository at all: it works
* offline and on a POM whose parent is not published yet, but it cannot see a problem that
* inheritance introduces, such as a dependency whose version comes from the parent's
* {@code dependencyManagement}. {@code effective}, the default, runs that pass and then builds
* the effective model as well, so parents and imported boms are resolved and the checks needing
* them run too. Both passes, because the effective build says nothing about the reactor around a
* POM: on its own it passes a project whose declared subproject is not on disk, which
* {@code mvn} refuses to read.
* <p>
* Nothing is ever written back to a POM. In {@code effective} mode what is resolved lands in the
* local repository, as it does for every Maven tool. {@code --local-repository} aims that
* somewhere else and {@code --temp-local-repository} at a directory made for the run and deleted
* when it ends, for a gate that wants the configured repository left as it was.
* <p>
* It stands up no container, so the directory holding a POM cannot make this process load
* anything through {@code .mvn/extensions.xml}, {@code maven.ext.class.path} or
* {@code .mvn/settings.xml}. The user's own settings are another matter: resolution reads them
* for mirrors, proxies and credentials, as it has to. The JVM is outside this:
* {@code bin/mvn} passes {@code .mvn/jvm.config} to the launcher before this process starts.
* <p>
* {@code -o} works in {@code effective} mode: resolution hits the local repository only and
* fails fast when a parent or BOM is absent. Combined with {@code --temp-local-repository} it
* proves that the POMs being validated are fully self-contained.
* <p>
* When more than one POM is given, {@code effective} mode pre-scans them all to build a bundle
* workspace: a parent that is named in the bundle is resolved from disk rather than from a
* repository, so the verdict does not depend on publication order and the run does not download
* what is already on disk.
*/
public class ValidateInvoker extends LookupInvoker<ValidateContext> {
/** Where Central lives, for the one case where no settings file names a repository. */
private static final String CENTRAL_URL = "https://repo.maven.apache.org/maven2";
public static final int OK = 0;
public static final int ERROR = 1;
/** Bad user input. Same meaning as in {@code mvnenc} and {@code mvnup}. */
public static final int BAD_OPERATION = 2;
/**
* Interrupted. Same meaning as in {@code mvnenc} and {@code mvnup}, and reachable only with a
* terminal attached: without one the signal reaches the JVM instead and the exit code is its.
* Left out of {@code --help} for that reason.
*/
public static final int CANCELED = 3;
/**
* Nothing was rejected, but something was reported. Numbered 4 rather than reusing a lower
* code: 0 to 3 mean the same thing across the Maven 4 tools, and a gate must be able to tell
* "this POM has warnings" from "mvnval broke" or "somebody hit Ctrl+C".
*/
public static final int WARNINGS = 4;
public ValidateInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
super(protoLookup, contextConsumer);
}
@Override
protected ValidateContext createContext(InvokerRequest invokerRequest) {
return new ValidateContext(
invokerRequest, (ValidateOptions) invokerRequest.options().orElse(null));
}
@Override
protected int execute(ValidateContext context) throws Exception {
Thread validationThread = Thread.currentThread();
AtomicBoolean cancellationRequested = new AtomicBoolean();
Terminal.SignalHandler previousHandler = context.terminal.handle(Terminal.Signal.INT, signal -> {
cancellationRequested.set(true);
validationThread.interrupt();
});
try {
OutputFormat format;
ValidationMode mode;
try {
// Checked here rather than while parsing: the parser knows the option names,
// not the values, and a value it did accept can still become anything once
// options are interpolated.
format = context.options().format().map(OutputFormat::parse).orElse(OutputFormat.TEXT);
mode = context.options().mode().map(ValidationMode::parse).orElse(ValidationMode.EFFECTIVE);
} catch (IllegalArgumentException e) {
context.logger.error(e.getMessage());
return BAD_OPERATION;
}
// -s/-ps/-is redirect the settings files that ApiRunner reads at session-creation time.
// Honouring them would require threading the alternate path into ApiRunner before it
// starts; that is a larger change than belongs here, so they are still refused.
// -o is different: it applies to the resolver session, which createSession() returns,
// and can be applied there after the session is built. Raw mode resolves nothing, so
// none of these apply there.
if (mode != ValidationMode.RAW) {
String refused = refusedSettingsOption(context.options());
if (refused != null) {
context.logger.error(refused
+ " cannot redirect the settings file mvnval reads."
+ " Use the default settings, or copy the relevant settings to the default location.");
return BAD_OPERATION;
}
}
if (context.options().localRepository().isPresent()
&& context.options().tempLocalRepository().orElse(false)) {
context.logger.error(
"--local-repository and --temp-local-repository name different directories; give one.");
return BAD_OPERATION;
}
String unusableRepository = unusableRepository(context);
if (unusableRepository != null) {
context.logger.error(unusableRepository);
return BAD_OPERATION;
}
Path localRepository = null;
// Only in effective mode: raw resolves nothing, so making a directory there could
// lose a run that would never have written to it.
if (mode != ValidationMode.RAW) {
try {
localRepository = localRepository(context);
} catch (IOException e) {
// Not ERROR: no POM was rejected, the run could not be set up.
context.logger.error("Could not create a temporary local repository: " + e);
return BAD_OPERATION;
}
}
Thread cleanup = temporary(context, localRepository) ? cleanupHook(localRepository, context) : null;
try {
List<Report> reports =
validateAll(context, createSession(localRepository), mode, cancellationRequested);
if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
return canceled(context);
}
// determineWriter, not context.writer: on a real run that field is still empty
// and this is what fills it.
int contextLines = context.options().context().orElse(2);
format.report(
reports,
context.cwd.get(),
determineWriter(context),
contextLines,
MessageUtils.isColorEnabled());
return exitCode(reports);
} finally {
if (cleanup != null) {
// Delete first, deregister second. The other order leaves a window where a
// signal arriving after the hook is gone kills the process before the
// deletion runs.
deleteRecursively(localRepository, context);
removeHook(cleanup);
}
}
} catch (UnsupportedOperationException e) {
// The ModelBuilder in this container has not written validate. Nothing was wrong with
// any POM, so this must not be ERROR, the code a gate reads as "a POM was rejected".
context.logger.error("This Maven installation cannot validate POMs: " + e.getMessage());
return BAD_OPERATION;
} catch (UserInterruptException e) {
return canceled(context);
} catch (Exception e) {
if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
return canceled(context);
}
// Without this an unexpected failure escapes to the CLI and becomes 2, which this tool
// documents as bad usage. Both siblings catch here too.
if (context.options().showErrors().orElse(false)) {
context.logger.error(e.getMessage(), e);
} else {
context.logger.error(e.getMessage());
}
return ERROR;
} finally {
context.terminal.handle(Terminal.Signal.INT, previousHandler);
if (cancellationRequested.get()) {
Thread.interrupted();
}
}
}
private static int canceled(ValidateContext context) {
Thread.interrupted();
context.logger.error("Validation canceled by user.");
return CANCELED;
}
/**
* Turns an unparseable command line into {@link #BAD_OPERATION}.
* <p>
* The base exits 1 for a bad argument, which is the code this tool uses for a POM it rejected.
* A gate has to be able to tell a typo in its own script from a POM that failed validation.
*/
@Override
protected void validate(ValidateContext context) throws Exception {
try {
super.validate(context);
} catch (InvokerException.ExitException e) {
throw e.getExitCode() == ERROR ? new InvokerException.ExitException(BAD_OPERATION) : e;
}
}
/** Whether stdout carries a document this run must not write anything else to. */
private static boolean json(ValidateContext context) {
return context.options()
.format()
.filter(OutputFormat.JSON.name()::equalsIgnoreCase)
.isPresent();
}
/**
* Keeps the version banner out of the document under {@code --format json}.
* <p>
* The base prints it whenever {@code effectiveVerbose()} holds, which a CI runner setting
* {@code RUNNER_DEBUG=1} makes true, so re-running a job with debug logging turned the
* document into something no parser accepts. Asking for {@code -V} still prints it, since
* that is what the flag is for.
*/
@Override
protected void preCommands(ValidateContext context) throws Exception {
if (json(context) && !context.options().showVersion().orElse(false)) {
return;
}
super.preCommands(context);
}
/**
* Suppresses resolver noise from the output unless the caller asked for it.
* <p>
* The resolver writes an {@code [INFO]} line the first time it reads a repository's prefix
* file. Under {@code --format json} that line breaks the document; in text mode it clutters
* output that is meant to list only POM problems. Neither case benefits from seeing it.
* The level is forced to {@code ERROR} ��� the same effect as {@code -q} ��� unless {@code -X}
* or {@code -e} is given, in which case the full log is what the caller wants.
* <p>
* The property must be set <em>before</em> calling {@link Slf4jConfiguration#setRootLoggerLevel}
* because that method itself logs at {@code INFO} when it overrides a value already set (e.g.
* a CI runner running with {@code DEBUG}), which would be the very noise we are suppressing.
*/
@Override
protected void configureLogging(ValidateContext context) throws Exception {
super.configureLogging(context);
if (!context.options().verbose().orElse(false)
&& !context.options().showErrors().orElse(false)) {
context.loggerLevel = Slf4jConfiguration.Level.ERROR;
System.setProperty(Constants.MAVEN_LOGGER_DEFAULT_LOG_LEVEL, "error");
context.slf4jConfiguration.setRootLoggerLevel(context.loggerLevel);
}
}
// The next five steps stand up dependency injection and read configuration. Nothing here uses
// either: the model builder comes from createSession(). Running them would let the directory
// holding the POM decide what this process loads, via .mvn/extensions.xml, .mvn/settings.xml,
// or maven.ext.class.path in .mvn/maven-user.properties. Refused one at a time rather than by
// replacing doInvoke, so a step added to the base class later still runs.
/** No container: nothing is looked up, and no extension or contributed property is loaded. */
@Override
protected void container(ValidateContext context) {}
/** No container, so no {@code PropertyContributor} to run. */
@Override
protected void postContainer(ValidateContext context) {}
/** No container to look up from. */
@Override
protected void lookup(ValidateContext context) {}
/** No {@code EventSpy} dispatch: this tool emits no build events. */
@Override
protected void init(ValidateContext context) {}
/**
* No settings step: the model builder runs on the session from {@link #createSession(Path)},
* which reads the user's settings itself when it has to resolve.
*/
@Override
protected void settings(ValidateContext context) {}
/**
* Creates the session the model builder runs on. Made here because {@code LookupInvoker} only
* ever builds a {@code ProtoSession} while {@link ModelBuilderRequest} needs a full
* {@link Session}. Left {@code protected} as a seam: building one is the expensive part of
* every test in this package, and the tests substitute a shared one.
*
* @return the session, never {@code null}
*/
protected Session createSession(@Nullable Path localRepository) {
Session session = ApiRunner.createSession(injector -> injector.bindImplicit(TransporterFactoryConfig.class));
if (localRepository != null) {
// Set on the session that came back, not passed to createSession, which prefers
// settings.getLocalRepository() over its argument and may in any case hand back a
// session another tool built. See derivedRepositories for why that happens.
session = session.withLocalRepository(session.createLocalRepository(localRepository));
}
return session.withRemoteRepositories(derivedRepositories(session));
}
/**
* Applies {@code --offline} to a session built by {@link #createSession}.
* <p>
* {@code ApiRunner.createSession} applies offline from the settings file. This supplements
* that: when the caller passes {@code -o} on the command line, the session is made offline
* regardless of what the settings say, by setting offline to true on the underlying resolver session.
*/
private static Session withOffline(Session session) {
// AbstractSession.withLocalRepository short-circuits when the path is unchanged
// (returns this). Mutate the underlying DefaultRepositorySystemSession in-place.
// Safe only because validateAll reassigns session immediately and does not retain
// the pre-mutation reference.
DefaultRepositorySystemSession rsession =
(DefaultRepositorySystemSession) InternalSession.from(session).getSession();
rsession.setOffline(true);
return session;
}
/**
* Installs a {@link BundleWorkspaceReader} on the resolver session so that parent and BOM
* lookups for POMs in the bundle are answered from disk rather than from a repository.
*/
private static Session withWorkspaceReader(Session session, BundleWorkspaceReader reader) {
// AbstractSession.withLocalRepository short-circuits when the path is unchanged
// (returns this). Mutate the underlying DefaultRepositorySystemSession in-place.
// Safe only because validateAll reassigns session immediately and does not retain
// the pre-mutation reference.
DefaultRepositorySystemSession rsession =
(DefaultRepositorySystemSession) InternalSession.from(session).getSession();
rsession.setWorkspaceReader(reader);
return session;
}
/**
* The repositories to resolve from, derived here rather than taken as they came.
* <p>
* {@code ApiRunner.createSession} asks its injector for an unqualified {@code Session}, and
* {@code InjectorImpl} registers a qualified {@code @Provides} under the unqualified key as
* well, so a provider belonging to a sibling tool can answer it and bring its own hardcoded
* repositories. Deriving the list from the settings gives the same answer whichever session
* came back, and keeps the repositories an operator declared: a parent that lives only in an
* internal repository has to resolve here exactly as it does under {@code mvn}.
* <p>
* The fallback matters only where no settings file declares a repository, which for a real
* installation means never: {@code conf/settings.xml} ships Central. It states Central with
* snapshots off, as that file does, because the two-argument
* {@link Session#createRemoteRepository(String, String)} takes the resolver's defaults
* instead, which enable snapshots and lower checksum checking to a warning.
*/
private static List<RemoteRepository> derivedRepositories(Session session) {
RepositoryFactory factory = session.getService(RepositoryFactory.class);
Profile settingsProfile = session.getService(SettingsBuilder.class)
.convert(org.apache.maven.api.settings.Profile.newBuilder()
.repositories(session.getSettings().getRepositories())
.build());
List<RemoteRepository> repositories = settingsProfile.getRepositories().stream()
.map(factory::createRemote)
.toList();
return repositories.isEmpty()
? List.of(factory.createRemote(Repository.newBuilder()
.id(RemoteRepository.CENTRAL_ID)
.url(CENTRAL_URL)
.releases(RepositoryPolicy.newBuilder().enabled("true").build())
.snapshots(
RepositoryPolicy.newBuilder().enabled("false").build())
.build()))
: repositories;
}
/**
* The transports resolution needs. {@code ApiRunner.createSession} registers none of its own,
* so a session built without these reports "No transporter factories registered" and cannot
* fetch from anywhere. {@code mvnup} carries the same block for the same reason.
*/
static class TransporterFactoryConfig {
@Provides
@Named(ApacheTransporterFactory.NAME)
static TransporterFactory apacheTransporterFactory(
ChecksumExtractor checksumExtractor, PathProcessor pathProcessor) {
return new ApacheTransporterFactory(checksumExtractor, pathProcessor);
}
@Provides
@Named(FileTransporterFactory.NAME)
static TransporterFactory fileTransporterFactory() {
return new FileTransporterFactory();
}
}
/** Whether {@code localRepository} is the throwaway directory this run made. */
private static boolean temporary(ValidateContext context, @Nullable Path localRepository) {
return localRepository != null
&& context.options().tempLocalRepository().orElse(false);
}
/**
* Deletes the throwaway directory when the process is signalled.
* <p>
* The {@code finally} covers every way out of {@code execute}, but not a signal, and a CI job
* killed on timeout is the case this option exists for. Without the hook a {@code SIGTERM} part way
* through a run left the populated directory behind. {@code SIGKILL} still escapes, as it escapes every hook.
*/
private static Thread cleanupHook(Path directory, ValidateContext context) {
Thread hook = new Thread(() -> deleteRecursively(directory, context), "mvnval-repo-cleanup");
Runtime.getRuntime().addShutdownHook(hook);
return hook;
}
/** Takes the hook off, unless the shutdown it guards against has already begun. */
private static void removeHook(Thread hook) {
try {
Runtime.getRuntime().removeShutdownHook(hook);
} catch (IllegalStateException e) {
// Shutdown is under way and the hook is running or about to; it does the same work.
}
}
/**
* The local repository to resolve into, or {@code null} to leave the configured one alone.
* The throwaway directory is made here rather than in {@link #createSession(Path)} so the
* caller owns it and can delete it on every path out.
*/
@Nullable
private static Path localRepository(ValidateContext context) throws IOException {
if (context.options().tempLocalRepository().orElse(false)) {
return Files.createTempDirectory("mvnval-repo-");
}
// maven.repo.local is how every other Maven command aims the local repository, so
// swallowing it while offering --local-repository would be the trap this tool refuses
// -o and -s to avoid. The option wins, being the more specific request.
return context.options()
.localRepository()
.or(() -> Optional.ofNullable(
context.protoSession.getEffectiveProperties().get(Constants.MAVEN_REPO_LOCAL)))
.map(context.cwd::resolve)
.orElse(null);
}
/**
* Deletes the temporary local repository, deepest entry first. A failure here is reported and
* not thrown: the verdict on the POMs is already given, and losing it over a leftover
* directory would be the wrong trade. A process killed before this runs leaves the directory
* behind, where the operating system's temporary cleanup finds it.
*/
private static void deleteRecursively(Path directory, ValidateContext context) {
if (!Files.exists(directory)) {
return;
}
try (Stream<Path> entries = Files.walk(directory)) {
for (Path entry : entries.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(entry);
}
} catch (IOException e) {
context.logger.warn("Could not delete the temporary local repository " + directory + ": " + e);
}
}
/**
* Names the first settings-redirect option given that this tool cannot act on, or
* {@code null} when none was given.
* <p>
* {@code -o} is intentionally absent: it is honoured in {@link #validateAll}.
*/
@Nullable
private static String refusedSettingsOption(ValidateOptions options) {
if (options.altUserSettings().isPresent()) {
return "--settings (-s)";
}
if (options.altProjectSettings().isPresent()) {
return "--project-settings (-ps)";
}
if (options.altInstallationSettings().isPresent()) {
// Both spellings, because the parser folds -gs into this one and naming only the
// long form tells someone who typed -gs about an option they did not use.
return "--install-settings (-is, -gs)";
}
return null;
}
private static List<Path> resolvePoms(ValidateContext context) {
List<String> args = context.options().poms().orElse(List.of());
return args.isEmpty()
? List.of(context.cwd.resolve("pom.xml"))
: args.stream()
.map(context.cwd::resolve)
.map(ValidateInvoker::sameFileSamePath)
.distinct()
.toList();
}
private static List<Report> validateAll(
ValidateContext context, Session session, ValidationMode mode, AtomicBoolean cancellationRequested) {
// Apply --offline after session creation: the session already has mirrors, proxies and
// credentials from the settings; making it offline only prevents the resolver from
// opening connections, which is exactly what -o requests.
if (context.options().offline().orElse(false)) {
session = withOffline(session);
}
List<Path> poms = resolvePoms(context);
ModelBuilder builder = session.getService(ModelBuilder.class);
// In effective mode with more than one POM, build a bundle workspace so that parent and
// BOM lookups for members of the bundle are answered from disk. This is safe to share
// across sessions because the index is immutable: it is built once here and never written
// to. The ordering bug that forced per-POM sessions arose from mappedSources, which is
// mutable session state; the workspace reader has no such shared mutable state.
if (mode == ValidationMode.EFFECTIVE && poms.size() > 1) {
BundleWorkspaceReader bundleReader = buildBundleWorkspace(poms, session, builder);
session = withWorkspaceReader(session, bundleReader);
}
final Session resolvedSession = session;
List<Report> reports = new ArrayList<>(poms.size());
for (Path pom : poms) {
if (cancellationRequested.get() || Thread.currentThread().isInterrupted()) {
break;
}
ModelBuilderRequest request = ModelBuilderRequest.builder()
.session(resolvedSession)
.source(Sources.buildSource(pom))
.requestType(RequestType.BUILD_PROJECT)
.userProperties(context.protoSession.getUserProperties())
.build();
// A session per POM. Sharing one made the verdict depend on argument order: derived
// sessions share mappedSources, so a groupId:artifactId registered while reading an
// earlier POM answered a later POM's parent lookup, and two files given in the other
// order came back with different problems. Sharing bought no reading either, since
// every pass walks the reactor from the root again.
reports.add(validateOne(request, builder.newSession(), mode));
}
return reports;
}
/**
* Pre-scans all input POMs to build the bundle workspace index.
* <p>
* The index maps {@code groupId:artifactId} to the POM file on disk. Scanning uses
* {@link ModelBuilder#buildRawModel} so that only the file is read and no parent resolution
* runs. A POM that cannot be parsed is skipped: the validation pass that follows will report
* it as broken, which is the right place for that verdict.
* <p>
* The index is keyed by GA rather than GAV because a bundle is expected to carry at most one
* version of any given module, and a version mismatch between a child's declaration and the
* parent found in the bundle is something the validator should report rather than a reason to
* fall through to the repository.
*/
private static BundleWorkspaceReader buildBundleWorkspace(List<Path> poms, Session session, ModelBuilder builder) {
Map<String, File> index = new HashMap<>();
Map<String, Model> models = new HashMap<>();
for (Path pom : poms) {
if (!Files.isRegularFile(pom) || !Files.isReadable(pom)) {
continue;
}
try {
ModelBuilderRequest req = ModelBuilderRequest.builder()
.session(session)
.source(Sources.buildSource(pom))
.requestType(RequestType.BUILD_PROJECT)
.build();
Model model = builder.buildRawModel(req);
String groupId = model.getGroupId() != null
? model.getGroupId()
: (model.getParent() != null ? model.getParent().getGroupId() : null);
String artifactId = model.getArtifactId();
if (groupId != null && artifactId != null) {
String ga = groupId + ":" + artifactId;
index.put(ga, pom.toFile());
models.put(ga, model);
}
} catch (Exception e) {
// Validation will report this POM as broken; skip it for the workspace index.
}
}
return new BundleWorkspaceReader(index, models);
}
private static Report validateOne(
ModelBuilderRequest request, ModelBuilderSession builderSession, ValidationMode mode) {
Path pom = request.getSource().getPath();
String unusable = unusable(pom);
if (unusable != null) {
return Report.failed(pom, unusable);
}
try {
return Report.of(pom, mode.problemsFor(builderSession, request));
} catch (ModelBuilderException e) {
// A result that explains nothing would report the file as clean, so use the exception.
ModelBuilderResult result = e.getResult();
return result != null && result.getProblemCollector().hasErrorProblems()
? Report.of(pom, ValidationMode.problemsOf(result))
: Report.failed(pom, describe(e));
} catch (UnsupportedOperationException e) {
// Past the catch-all below, or it would be reported as this file being bad. execute()
// turns it into BAD_OPERATION: the tool cannot run at all here.
throw e;
} catch (Exception e) {
// One bad file must not lose the verdict on the others.
return Report.failed(pom, describe(e));
}
}
/**
* Says why {@code --local-repository} cannot be used, or {@code null} when it can. A path that
* exists and is not a directory reaches the resolver and comes back as a ClassCastException
* between two exception types, reported against the POM as though the POM were at fault.
*/
@Nullable
private static String unusableRepository(ValidateContext context) {
return context.options()
.localRepository()
.map(context.cwd::resolve)
.filter(Files::exists)
.filter(path -> !Files.isDirectory(path))
.map(path -> "--local-repository " + path + " is not a directory.")
.orElse(null);
}
/**
* The one spelling of a path, so that naming a file twice reports it once. {@code "pom.xml"}
* and {@code "./pom.xml"} differ before normalising, and on Windows or macOS {@code POM.XML}
* is the same file again. {@link Path#toRealPath} settles all of those, including a symbolic
* link, and needs the file to be there; when it is not, the normalised path goes through and
* {@code unusable} reports it missing.
*/
private static Path sameFileSamePath(Path pom) {
try {
return pom.toRealPath();
} catch (IOException e) {
return pom.normalize();
}
}
/**
* Says why the file cannot be validated, or {@code null} when it can. A directory gets its own
* message because {@code mvn -f} accepts one and looks for a {@code pom.xml} inside, so it is
* the mistake most likely to be made here.
*/
@Nullable
private static String unusable(Path pom) {
if (Files.isDirectory(pom)) {
return "is a directory, not a POM file";
}
if (!Files.exists(pom)) {
return "does not exist";
}
if (!Files.isRegularFile(pom)) {
return "not a regular file";
}
return Files.isReadable(pom) ? null : "not readable";
}
/** Falls back to the class name rather than printing {@code null} for a message-less one. */
private static String describe(Exception e) {
String message = e.getMessage();
return message != null && !message.isEmpty() ? message : e.toString();
}
private static int exitCode(List<Report> reports) {
if (reports.stream().anyMatch(Report::hasErrors)) {
return ERROR;
}
return reports.stream().allMatch(Report::clean) ? OK : WARNINGS;
}
}