View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.cling.invoker.mvn;
20  
21  import java.io.FileNotFoundException;
22  import java.nio.file.Files;
23  import java.nio.file.Path;
24  import java.util.ArrayList;
25  import java.util.LinkedHashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Optional;
29  import java.util.function.Consumer;
30  import java.util.regex.Matcher;
31  import java.util.regex.Pattern;
32  import java.util.stream.Collectors;
33  
34  import org.apache.maven.InternalErrorException;
35  import org.apache.maven.Maven;
36  import org.apache.maven.api.Constants;
37  import org.apache.maven.api.MonotonicClock;
38  import org.apache.maven.api.annotations.Nullable;
39  import org.apache.maven.api.cli.InvokerRequest;
40  import org.apache.maven.api.cli.Logger;
41  import org.apache.maven.api.cli.mvn.MavenOptions;
42  import org.apache.maven.api.services.BuilderProblem;
43  import org.apache.maven.api.services.Lookup;
44  import org.apache.maven.api.services.Sources;
45  import org.apache.maven.api.services.ToolchainsBuilder;
46  import org.apache.maven.api.services.ToolchainsBuilderRequest;
47  import org.apache.maven.api.services.ToolchainsBuilderResult;
48  import org.apache.maven.api.services.model.ModelProcessor;
49  import org.apache.maven.api.toolchain.PersistedToolchains;
50  import org.apache.maven.cling.event.ExecutionEventLogger;
51  import org.apache.maven.cling.invoker.CliUtils;
52  import org.apache.maven.cling.invoker.LookupContext;
53  import org.apache.maven.cling.invoker.LookupInvoker;
54  import org.apache.maven.cling.transfer.ConsoleMavenTransferListener;
55  import org.apache.maven.cling.transfer.QuietMavenTransferListener;
56  import org.apache.maven.cling.transfer.SimplexTransferListener;
57  import org.apache.maven.cling.transfer.Slf4jMavenTransferListener;
58  import org.apache.maven.exception.DefaultExceptionHandler;
59  import org.apache.maven.exception.ExceptionHandler;
60  import org.apache.maven.exception.ExceptionSummary;
61  import org.apache.maven.execution.DefaultMavenExecutionRequest;
62  import org.apache.maven.execution.ExecutionListener;
63  import org.apache.maven.execution.MavenExecutionRequest;
64  import org.apache.maven.execution.MavenExecutionResult;
65  import org.apache.maven.execution.ProfileActivation;
66  import org.apache.maven.execution.ProjectActivation;
67  import org.apache.maven.jline.MessageUtils;
68  import org.apache.maven.lifecycle.LifecycleExecutionException;
69  import org.apache.maven.logging.LoggingExecutionListener;
70  import org.apache.maven.logging.MavenTransferListener;
71  import org.apache.maven.project.MavenProject;
72  import org.apache.maven.toolchain.model.ToolchainModel;
73  import org.eclipse.aether.DefaultRepositoryCache;
74  import org.eclipse.aether.transfer.TransferListener;
75  
76  import static java.util.Comparator.comparing;
77  
78  /**
79   * The Maven invoker, that expects whole Maven on classpath and invokes it.
80   */
81  public class MavenInvoker extends LookupInvoker<MavenContext> {
82      public MavenInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
83          super(protoLookup, contextConsumer);
84      }
85  
86      @Override
87      protected MavenContext createContext(InvokerRequest invokerRequest) {
88          return new MavenContext(
89                  invokerRequest, true, (MavenOptions) invokerRequest.options().orElse(null));
90      }
91  
92      @Override
93      protected int execute(MavenContext context) throws Exception {
94          MavenExecutionRequest request = prepareMavenExecutionRequest();
95          toolchains(context, request);
96          populateRequest(context, context.lookup, request);
97          return doExecute(context, request);
98      }
99  
100     protected MavenExecutionRequest prepareMavenExecutionRequest() throws Exception {
101         // explicitly fill in "defaults"?
102         DefaultMavenExecutionRequest mavenExecutionRequest = new DefaultMavenExecutionRequest();
103         mavenExecutionRequest.setRepositoryCache(new DefaultRepositoryCache());
104         mavenExecutionRequest.setInteractiveMode(true);
105         mavenExecutionRequest.setCacheTransferError(false);
106         mavenExecutionRequest.setIgnoreInvalidArtifactDescriptor(true);
107         mavenExecutionRequest.setIgnoreMissingArtifactDescriptor(true);
108         mavenExecutionRequest.setRecursive(true);
109         mavenExecutionRequest.setReactorFailureBehavior(MavenExecutionRequest.REACTOR_FAIL_FAST);
110         mavenExecutionRequest.setStartInstant(MonotonicClock.now());
111         mavenExecutionRequest.setLoggingLevel(MavenExecutionRequest.LOGGING_LEVEL_INFO);
112         mavenExecutionRequest.setDegreeOfConcurrency(1);
113         mavenExecutionRequest.setBuilderId("singlethreaded");
114         return mavenExecutionRequest;
115     }
116 
117     @Override
118     protected void lookup(MavenContext context) throws Exception {
119         if (context.maven == null) {
120             super.lookup(context);
121             context.maven = context.lookup.lookup(Maven.class);
122         }
123     }
124 
125     @Override
126     protected void postCommands(MavenContext context) throws Exception {
127         super.postCommands(context);
128 
129         Logger logger = context.logger;
130         if (context.options().relaxedChecksums().orElse(false)) {
131             logger.info("Disabling strict checksum verification on all artifact downloads.");
132         } else if (context.options().strictChecksums().orElse(false)) {
133             logger.info("Enabling strict checksum verification on all artifact downloads.");
134         }
135     }
136 
137     protected void toolchains(MavenContext context, MavenExecutionRequest request) throws Exception {
138         Path userToolchainsFile = null;
139         if (context.options().altUserToolchains().isPresent()) {
140             userToolchainsFile =
141                     context.cwd.resolve(context.options().altUserToolchains().get());
142 
143             if (!Files.isRegularFile(userToolchainsFile)) {
144                 throw new FileNotFoundException(
145                         "The specified user toolchains file does not exist: " + userToolchainsFile);
146             }
147         } else {
148             String userToolchainsFileStr =
149                     context.protoSession.getEffectiveProperties().get(Constants.MAVEN_USER_TOOLCHAINS);
150             if (userToolchainsFileStr != null) {
151                 userToolchainsFile = context.cwd.resolve(userToolchainsFileStr);
152             }
153         }
154 
155         Path installationToolchainsFile = null;
156         if (context.options().altInstallationToolchains().isPresent()) {
157             installationToolchainsFile = context.cwd.resolve(
158                     context.options().altInstallationToolchains().get());
159 
160             if (!Files.isRegularFile(installationToolchainsFile)) {
161                 throw new FileNotFoundException(
162                         "The specified installation toolchains file does not exist: " + installationToolchainsFile);
163             }
164         } else {
165             String installationToolchainsFileStr =
166                     context.protoSession.getEffectiveProperties().get(Constants.MAVEN_INSTALLATION_TOOLCHAINS);
167             if (installationToolchainsFileStr != null) {
168                 installationToolchainsFile = context.installationDirectory
169                         .resolve(installationToolchainsFileStr)
170                         .normalize();
171             }
172         }
173 
174         request.setInstallationToolchainsFile(
175                 installationToolchainsFile != null ? installationToolchainsFile.toFile() : null);
176         request.setUserToolchainsFile(userToolchainsFile != null ? userToolchainsFile.toFile() : null);
177 
178         ToolchainsBuilderRequest toolchainsRequest = ToolchainsBuilderRequest.builder()
179                 .session(context.protoSession)
180                 .installationToolchainsSource(
181                         installationToolchainsFile != null && Files.isRegularFile(installationToolchainsFile)
182                                 ? Sources.fromPath(installationToolchainsFile)
183                                 : null)
184                 .userToolchainsSource(
185                         userToolchainsFile != null && Files.isRegularFile(userToolchainsFile)
186                                 ? Sources.fromPath(userToolchainsFile)
187                                 : null)
188                 .build();
189 
190         context.eventSpyDispatcher.onEvent(toolchainsRequest);
191 
192         context.logger.debug("Reading installation toolchains from '" + installationToolchainsFile + "'");
193         context.logger.debug("Reading user toolchains from '" + userToolchainsFile + "'");
194 
195         ToolchainsBuilderResult toolchainsResult =
196                 context.lookup.lookup(ToolchainsBuilder.class).build(toolchainsRequest);
197 
198         context.eventSpyDispatcher.onEvent(toolchainsResult);
199 
200         context.effectiveToolchains = toolchainsResult.getEffectiveToolchains();
201 
202         if (toolchainsResult.getProblems().hasWarningProblems()) {
203             int totalProblems = toolchainsResult.getProblems().totalProblemsReported();
204             context.logger.info("");
205             context.logger.info(String.format(
206                     "%s %s encountered while building the effective toolchains (use -e to see details)",
207                     totalProblems, (totalProblems == 1) ? "problem was" : "problems were"));
208 
209             if (context.options().showErrors().orElse(false)) {
210                 for (BuilderProblem problem :
211                         toolchainsResult.getProblems().problems().toList()) {
212                     context.logger.warn(problem.getMessage() + " @ " + problem.getLocation());
213                 }
214             }
215 
216             context.logger.info("");
217         }
218     }
219 
220     @Override
221     protected void populateRequest(MavenContext context, Lookup lookup, MavenExecutionRequest request)
222             throws Exception {
223         super.populateRequest(context, lookup, request);
224         if (context.invokerRequest.rootDirectory().isEmpty()) {
225             // maven requires this to be set; so default it (and see below at POM)
226             request.setMultiModuleProjectDirectory(
227                     context.invokerRequest.topDirectory().toFile());
228             request.setRootDirectory(context.invokerRequest.topDirectory());
229         }
230 
231         request.setToolchains(
232                 Optional.ofNullable(context.effectiveToolchains).map(PersistedToolchains::getToolchains).stream()
233                         .flatMap(List::stream)
234                         .map(ToolchainModel::new)
235                         .collect(Collectors.groupingBy(ToolchainModel::getType)));
236 
237         request.setNoSnapshotUpdates(context.options().suppressSnapshotUpdates().orElse(false));
238         request.setGoals(context.options().goals().orElse(List.of()));
239         request.setReactorFailureBehavior(determineReactorFailureBehaviour(context));
240         request.setRecursive(!context.options().nonRecursive().orElse(!request.isRecursive()));
241         request.setOffline(context.options().offline().orElse(request.isOffline()));
242         request.setUpdateSnapshots(context.options().updateSnapshots().orElse(false));
243         request.setGlobalChecksumPolicy(determineGlobalChecksumPolicy(context));
244 
245         Path pom = determinePom(context, lookup);
246         if (pom != null) {
247             request.setPom(pom.toFile());
248             if (pom.getParent() != null) {
249                 request.setBaseDirectory(pom.getParent().toFile());
250             }
251 
252             // project present, but we could not determine rootDirectory: extra work needed
253             if (context.invokerRequest.rootDirectory().isEmpty()) {
254                 Path rootDirectory = CliUtils.findMandatoryRoot(context.invokerRequest.topDirectory());
255                 request.setMultiModuleProjectDirectory(rootDirectory.toFile());
256                 request.setRootDirectory(rootDirectory);
257             }
258         }
259 
260         request.setTransferListener(determineTransferListener(
261                 context, context.options().noTransferProgress().orElse(false)));
262         request.setExecutionListener(determineExecutionListener(context));
263 
264         request.setResumeFrom(context.options().resumeFrom().orElse(null));
265         request.setResume(context.options().resume().orElse(false));
266         request.setMakeBehavior(determineMakeBehavior(context));
267         request.setCacheNotFound(context.options().cacheArtifactNotFound().orElse(true));
268         request.setCacheTransferError(false);
269 
270         if (context.options().strictArtifactDescriptorPolicy().orElse(false)) {
271             request.setIgnoreMissingArtifactDescriptor(false);
272             request.setIgnoreInvalidArtifactDescriptor(false);
273         } else {
274             request.setIgnoreMissingArtifactDescriptor(true);
275             request.setIgnoreInvalidArtifactDescriptor(true);
276         }
277 
278         request.setIgnoreTransitiveRepositories(
279                 context.options().ignoreTransitiveRepositories().orElse(false));
280 
281         performProjectActivation(context, request.getProjectActivation());
282         performProfileActivation(context, request.getProfileActivation());
283 
284         //
285         // Builder, concurrency and parallelism
286         //
287         // We preserve the existing methods for builder selection which is to look for various inputs in the threading
288         // configuration. We don't have an easy way to allow a pluggable builder to provide its own configuration
289         // parameters but this is sufficient for now. Ultimately we want components like Builders to provide a way to
290         // extend the command line to accept its own configuration parameters.
291         //
292         if (context.options().threads().isPresent()) {
293             int degreeOfConcurrency =
294                     calculateDegreeOfConcurrency(context.options().threads().get());
295             if (degreeOfConcurrency > 1) {
296                 request.setBuilderId("multithreaded");
297                 request.setDegreeOfConcurrency(degreeOfConcurrency);
298             }
299         }
300 
301         //
302         // Allow the builder to be overridden by the user if requested. The builders are now pluggable.
303         //
304         if (context.options().builder().isPresent()) {
305             request.setBuilderId(context.options().builder().get());
306         }
307     }
308 
309     protected Path determinePom(MavenContext context, Lookup lookup) {
310         Path current = context.cwd.get();
311         if (context.options().alternatePomFile().isPresent()) {
312             current = context.cwd.resolve(context.options().alternatePomFile().get());
313         }
314         ModelProcessor modelProcessor =
315                 lookup.lookupOptional(ModelProcessor.class).orElse(null);
316         if (modelProcessor != null) {
317             return modelProcessor.locateExistingPom(current);
318         } else {
319             return Files.isRegularFile(current) ? current : null;
320         }
321     }
322 
323     protected String determineReactorFailureBehaviour(MavenContext context) {
324         if (context.options().failFast().isPresent()) {
325             return MavenExecutionRequest.REACTOR_FAIL_FAST;
326         } else if (context.options().failAtEnd().isPresent()) {
327             return MavenExecutionRequest.REACTOR_FAIL_AT_END;
328         } else if (context.options().failNever().isPresent()) {
329             return MavenExecutionRequest.REACTOR_FAIL_NEVER;
330         } else {
331             return MavenExecutionRequest.REACTOR_FAIL_FAST;
332         }
333     }
334 
335     protected String determineGlobalChecksumPolicy(MavenContext context) {
336         if (context.options().strictChecksums().orElse(false)) {
337             return MavenExecutionRequest.CHECKSUM_POLICY_FAIL;
338         } else if (context.options().relaxedChecksums().orElse(false)) {
339             return MavenExecutionRequest.CHECKSUM_POLICY_WARN;
340         } else {
341             return null;
342         }
343     }
344 
345     protected ExecutionListener determineExecutionListener(MavenContext context) {
346         ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory());
347         if (context.eventSpyDispatcher != null) {
348             listener = context.eventSpyDispatcher.chainListener(listener);
349         }
350         return new LoggingExecutionListener(listener, determineBuildEventListener(context));
351     }
352 
353     protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) {
354         boolean quiet = context.options().quiet().orElse(false);
355         boolean logFile = context.options().logFile().isPresent();
356         boolean quietCI = context.invokerRequest.ciInfo().isPresent()
357                 && !context.options().forceInteractive().orElse(false);
358 
359         TransferListener delegate;
360         if (quiet || noTransferProgress || quietCI) {
361             delegate = new QuietMavenTransferListener();
362         } else if (context.interactive && !logFile) {
363             if (context.simplexTransferListener == null) {
364                 SimplexTransferListener simplex = new SimplexTransferListener(new ConsoleMavenTransferListener(
365                         context.invokerRequest.messageBuilderFactory(),
366                         context.terminal.writer(),
367                         context.invokerRequest.effectiveVerbose()));
368                 context.closeables.add(simplex);
369                 context.simplexTransferListener = simplex;
370             }
371             delegate = context.simplexTransferListener;
372         } else {
373             delegate = new Slf4jMavenTransferListener();
374         }
375         return new MavenTransferListener(delegate, determineBuildEventListener(context));
376     }
377 
378     protected String determineMakeBehavior(MavenContext context) {
379         if (context.options().alsoMake().isPresent()
380                 && context.options().alsoMakeDependents().isEmpty()) {
381             return MavenExecutionRequest.REACTOR_MAKE_UPSTREAM;
382         } else if (context.options().alsoMake().isEmpty()
383                 && context.options().alsoMakeDependents().isPresent()) {
384             return MavenExecutionRequest.REACTOR_MAKE_DOWNSTREAM;
385         } else if (context.options().alsoMake().isPresent()
386                 && context.options().alsoMakeDependents().isPresent()) {
387             return MavenExecutionRequest.REACTOR_MAKE_BOTH;
388         } else {
389             return null;
390         }
391     }
392 
393     protected void performProjectActivation(MavenContext context, ProjectActivation projectActivation) {
394         if (context.options().projects().isPresent()
395                 && !context.options().projects().get().isEmpty()) {
396             List<String> optionValues = context.options().projects().get();
397             for (final String optionValue : optionValues) {
398                 for (String token : optionValue.split(",")) {
399                     String selector = token.trim();
400                     boolean active = true;
401                     if (!selector.isEmpty()) {
402                         if (selector.charAt(0) == '-' || selector.charAt(0) == '!') {
403                             active = false;
404                             selector = selector.substring(1);
405                         } else if (token.charAt(0) == '+') {
406                             selector = selector.substring(1);
407                         }
408                     }
409                     boolean optional = false;
410                     if (!selector.isEmpty() && selector.charAt(0) == '?') {
411                         optional = true;
412                         selector = selector.substring(1);
413                     }
414                     projectActivation.addProjectActivation(selector, active, optional);
415                 }
416             }
417         }
418     }
419 
420     protected void performProfileActivation(MavenContext context, ProfileActivation profileActivation) {
421         if (context.options().activatedProfiles().isPresent()
422                 && !context.options().activatedProfiles().get().isEmpty()) {
423             List<String> optionValues = context.options().activatedProfiles().get();
424             for (final String optionValue : optionValues) {
425                 for (String token : optionValue.split(",")) {
426                     String profileId = token.trim();
427                     boolean active = true;
428                     if (!profileId.isEmpty()) {
429                         if (profileId.charAt(0) == '-' || profileId.charAt(0) == '!') {
430                             active = false;
431                             profileId = profileId.substring(1);
432                         } else if (token.charAt(0) == '+') {
433                             profileId = profileId.substring(1);
434                         }
435                     }
436                     boolean optional = false;
437                     if (!profileId.isEmpty() && profileId.charAt(0) == '?') {
438                         optional = true;
439                         profileId = profileId.substring(1);
440                     }
441                     profileActivation.addProfileActivation(profileId, active, optional);
442                 }
443             }
444         }
445     }
446 
447     protected int doExecute(MavenContext context, MavenExecutionRequest request) throws Exception {
448         context.eventSpyDispatcher.onEvent(request);
449 
450         MavenExecutionResult result;
451         try {
452             result = context.maven.execute(request);
453             context.eventSpyDispatcher.onEvent(result);
454         } finally {
455             context.eventSpyDispatcher.close();
456         }
457 
458         if (result.hasExceptions()) {
459             ExceptionHandler handler = new DefaultExceptionHandler();
460             Map<String, String> references = new LinkedHashMap<>();
461             List<MavenProject> failedProjects = new ArrayList<>();
462 
463             for (Throwable exception : result.getExceptions()) {
464                 ExceptionSummary summary = handler.handleException(exception);
465                 logSummary(context, summary, references, "");
466 
467                 if (exception instanceof LifecycleExecutionException lifecycleExecutionException) {
468                     failedProjects.add(lifecycleExecutionException.getProject());
469                 }
470             }
471 
472             context.logger.error("");
473 
474             if (!context.options().showErrors().orElse(false)) {
475                 context.logger.error("To see the full stack trace of the errors, re-run Maven with the '"
476                         + MessageUtils.builder().strong("-e") + "' switch");
477             }
478             if (!context.invokerRequest.effectiveVerbose()) {
479                 context.logger.error("Re-run Maven using the '"
480                         + MessageUtils.builder().strong("-X") + "' switch to enable verbose output");
481             }
482 
483             if (!references.isEmpty()) {
484                 context.logger.error("");
485                 context.logger.error("For more information about the errors and possible solutions"
486                         + ", please read the following articles:");
487 
488                 for (Map.Entry<String, String> entry : references.entrySet()) {
489                     context.logger.error(MessageUtils.builder().strong(entry.getValue()) + " " + entry.getKey());
490                 }
491             }
492 
493             if (result.canResume()) {
494                 logBuildResumeHint(context, "mvn [args] -r");
495             } else if (!failedProjects.isEmpty()) {
496                 List<MavenProject> sortedProjects = result.getTopologicallySortedProjects();
497 
498                 // Sort the failedProjects list in the topologically sorted order.
499                 failedProjects.sort(comparing(sortedProjects::indexOf));
500 
501                 MavenProject firstFailedProject = failedProjects.get(0);
502                 if (!firstFailedProject.equals(sortedProjects.get(0))) {
503                     String resumeFromSelector = getResumeFromSelector(sortedProjects, firstFailedProject);
504                     logBuildResumeHint(context, "mvn [args] -rf " + resumeFromSelector);
505                 }
506             }
507 
508             if (context.options().failNever().orElse(false)) {
509                 context.logger.info("Build failures were ignored.");
510                 return 0;
511             } else {
512                 return 1;
513             }
514         } else {
515             return 0;
516         }
517     }
518 
519     protected void logBuildResumeHint(MavenContext context, String resumeBuildHint) {
520         context.logger.error("");
521         context.logger.error("After correcting the problems, you can resume the build with the command");
522         context.logger.error(
523                 MessageUtils.builder().a("  ").strong(resumeBuildHint).toString());
524     }
525 
526     /**
527      * A helper method to determine the value to resume the build with {@code -rf} taking into account the edge case
528      *   where multiple modules in the reactor have the same artifactId.
529      * <p>
530      * {@code -rf :artifactId} will pick up the first module which matches, but when multiple modules in the reactor
531      *   have the same artifactId, effective failed module might be later in build reactor.
532      * This means that developer will either have to type groupId or wait for build execution of all modules which
533      *   were fine, but they are still before one which reported errors.
534      * <p>Then the returned value is {@code groupId:artifactId} when there is a name clash and
535      * {@code :artifactId} if there is no conflict.
536      * This method is made package-private for testing purposes.
537      *
538      * @param mavenProjects Maven projects which are part of build execution.
539      * @param firstFailedProject The first project which has failed.
540      * @return Value for -rf flag to resume build exactly from place where it failed ({@code :artifactId} in general
541      * and {@code groupId:artifactId} when there is a name clash).
542      */
543     protected String getResumeFromSelector(List<MavenProject> mavenProjects, MavenProject firstFailedProject) {
544         boolean hasOverlappingArtifactId = mavenProjects.stream()
545                         .filter(project -> firstFailedProject.getArtifactId().equals(project.getArtifactId()))
546                         .count()
547                 > 1;
548 
549         if (hasOverlappingArtifactId) {
550             return firstFailedProject.getGroupId() + ":" + firstFailedProject.getArtifactId();
551         }
552 
553         return ":" + firstFailedProject.getArtifactId();
554     }
555 
556     protected static final Pattern NEXT_LINE = Pattern.compile("\r?\n");
557 
558     protected static final Pattern LAST_ANSI_SEQUENCE = Pattern.compile("(\u001B\\[[;\\d]*[ -/]*[@-~])[^\u001B]*$");
559 
560     protected static final String ANSI_RESET = "\u001B\u005Bm";
561 
562     protected void logSummary(
563             MavenContext context, ExceptionSummary summary, Map<String, String> references, String indent) {
564         String referenceKey = "";
565 
566         if (summary.getReference() != null && !summary.getReference().isEmpty()) {
567             referenceKey =
568                     references.computeIfAbsent(summary.getReference(), k -> "[Help " + (references.size() + 1) + "]");
569         }
570 
571         String msg = summary.getMessage();
572 
573         if (!referenceKey.isEmpty()) {
574             if (msg.indexOf('\n') < 0) {
575                 msg += " -> " + MessageUtils.builder().strong(referenceKey);
576             } else {
577                 msg += "\n-> " + MessageUtils.builder().strong(referenceKey);
578             }
579         }
580 
581         String[] lines = NEXT_LINE.split(msg);
582         String currentColor = "";
583 
584         for (int i = 0; i < lines.length; i++) {
585             // add eventual current color inherited from previous line
586             String line = currentColor + lines[i];
587 
588             // look for last ANSI escape sequence to check if nextColor
589             Matcher matcher = LAST_ANSI_SEQUENCE.matcher(line);
590             String nextColor = "";
591             if (matcher.find()) {
592                 nextColor = matcher.group(1);
593                 if (ANSI_RESET.equals(nextColor)) {
594                     // last ANSI escape code is reset: no next color
595                     nextColor = "";
596                 }
597             }
598 
599             // effective line, with indent and reset if end is colored
600             line = indent + line + ("".equals(nextColor) ? "" : ANSI_RESET);
601 
602             if ((i == lines.length - 1)
603                     && (context.options().showErrors().orElse(false)
604                             || (summary.getException() instanceof InternalErrorException))) {
605                 context.logger.error(line, summary.getException());
606             } else {
607                 context.logger.error(line);
608             }
609 
610             currentColor = nextColor;
611         }
612 
613         indent += "  ";
614 
615         for (ExceptionSummary child : summary.getChildren()) {
616             logSummary(context, child, references, indent);
617         }
618     }
619 }