From 410a539781b4eb2af9e17a888d9c49a519a4a1f8 Mon Sep 17 00:00:00 2001 From: Vaclav Haisman Date: Sun, 20 Sep 2026 09:22:50 +0200 Subject: [PATCH] Expose Maven logging output capabilities Provide a singleton service describing the current logging destination and charset, plus an immutable execution-request data map for plugins compiled against older Maven APIs. Capture the map at the start of every Maven execution, preserving retained maps through request reuse and cleanup. Capture the actual logging route, preserve initialization and cleanup behavior, and treat negative exec-provider probes as unknown. Cover injection, request compatibility access, resident lifecycle where supported, and terminal detection. --- .../java/org/apache/maven/DefaultMaven.java | 5 + .../maven/logging/OutputCapabilities.java | 77 +++++++ .../internal/DefaultOutputCapabilities.java | 110 +++++++++ .../resources/META-INF/maven/extension.xml | 1 + .../maven/OutputCapabilitiesRequestTest.java | 217 ++++++++++++++++++ .../DefaultOutputCapabilitiesTest.java | 98 ++++++++ .../internal/OutputCapabilitiesMapTest.java | 183 +++++++++++++++ .../java/org/apache/maven/cli/CliRequest.java | 7 + .../java/org/apache/maven/cli/MavenCli.java | 28 ++- .../internal/TerminalOutputCapabilities.java | 139 +++++++++++ .../slf4j/simple/MavenSlf4jSimpleFriend.java | 55 +++++ .../cli/MavenCliOutputCapabilitiesTest.java | 86 +++++++ .../logging/internal/ExecTerminalProbe.java | 54 +++++ .../TerminalOutputCapabilitiesTest.java | 158 +++++++++++++ .../simple/MavenSlf4jSimpleFriendTest.java | 142 ++++++++++++ 15 files changed, 1358 insertions(+), 2 deletions(-) create mode 100644 maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java create mode 100644 maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java create mode 100644 maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java create mode 100644 maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java create mode 100644 maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java create mode 100644 maven-embedder/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java create mode 100644 maven-embedder/src/test/java/org/apache/maven/cli/MavenCliOutputCapabilitiesTest.java create mode 100644 maven-embedder/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java create mode 100644 maven-embedder/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java create mode 100644 maven-embedder/src/test/java/org/slf4j/simple/MavenSlf4jSimpleFriendTest.java diff --git a/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index 976b574fface..e0af7fbbe190 100644 --- a/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -47,6 +47,7 @@ import org.apache.maven.internal.aether.MavenChainedWorkspaceReader; import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; import org.apache.maven.lifecycle.internal.LifecycleStarter; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.apache.maven.model.Prerequisites; import org.apache.maven.model.building.ModelProblem; import org.apache.maven.model.building.Result; @@ -93,6 +94,9 @@ public class DefaultMaven implements Maven { @Inject private RepositorySystemSessionFactory repositorySessionFactory; + @Inject + private DefaultOutputCapabilities outputCapabilities; + @Inject @Named(GraphBuilder.HINT) private GraphBuilder graphBuilder; @@ -102,6 +106,7 @@ public MavenExecutionResult execute(MavenExecutionRequest request) { MavenExecutionResult result; try { + request.getData().put(DefaultOutputCapabilities.REQUEST_DATA_KEY, outputCapabilities.asMap()); result = doExecute(request); } catch (OutOfMemoryError e) { result = addExceptionToResult(new DefaultMavenExecutionResult(), e); diff --git a/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java b/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java new file mode 100644 index 000000000000..d7b1189cdc61 --- /dev/null +++ b/maven-core/src/main/java/org/apache/maven/logging/OutputCapabilities.java @@ -0,0 +1,77 @@ +/* + * 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.logging; + +import java.nio.charset.Charset; +import java.util.Optional; + +/** + * Describes the current destination of Maven logging, including plugin log messages. + * The information follows logging configuration and is shared by all projects in a build. + * It does not describe files or streams written directly by a plugin. + * + *

Plugins compiled against older Maven APIs can obtain the same information without + * referencing this interface through + * {@code session.getRequest().getData().get("maven.logging.outputCapabilities")}. + * The value is an immutable {@code Map}. Its {@code destination} + * entry is always present and contains {@code CONSOLE}, {@code FILE}, {@code REDIRECTED}, + * or {@code UNKNOWN}. Its {@code encoding} entry contains the canonical + * {@link Charset#name()} when known and is otherwise absent, independently of destination. + * An absent request-data entry means the service is unavailable (for example, on older + * Maven versions); a present map with {@code UNKNOWN} means detection is unavailable. + * Clients should ignore additional keys and treat unfamiliar destinations as unknown. + * + *

The map captures the logging configuration when + * {@link org.apache.maven.Maven#execute(org.apache.maven.execution.MavenExecutionRequest)} + * begins, before validation and lifecycle callbacks. The entry is absent before execution + * unless it remains from an earlier execution of the same request. Each execution replaces + * the entry with a fresh capture, including when the request is copied or reused. + * The map and its collection views remain unchanged through logging cleanup, + * reconfiguration, and subsequent invocations. A retained map captured while information + * is unknown remains unknown. Cleanup does not remove the entry from the request. + * Unlike the map, this component's getters describe the current logging configuration + * and may therefore differ from a retained request's map after reconfiguration or cleanup. + */ +public interface OutputCapabilities { + /** The known kind of logging destination. */ + enum Destination { + /** Output is attached to a terminal, independently of whether color is enabled. */ + CONSOLE, + /** Maven or its logging provider opened an explicit file destination. */ + FILE, + /** Output is not attached to a terminal; its final destination is not known. */ + REDIRECTED, + /** The logging destination could not be determined. */ + UNKNOWN + } + + /** + * Returns the known kind of logging destination. + * @return the destination, never {@code null} + */ + Destination getDestination(); + + /** + * Returns the charset selected for the logging output route, if known. + * Encoding may be known even when the destination is unknown. It does not imply + * support for terminal control sequences or guarantee that a font contains a glyph. + * @return the output charset, or an empty optional when it cannot be determined + */ + Optional getEncoding(); +} diff --git a/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java b/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java new file mode 100644 index 000000000000..917c3557f024 --- /dev/null +++ b/maven-core/src/main/java/org/apache/maven/logging/internal/DefaultOutputCapabilities.java @@ -0,0 +1,110 @@ +/* + * 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.logging.internal; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.logging.OutputCapabilities; + +/** + * Internal bridge between logging setup and the read-only component exposed to plugins. + * Publication and restoration follow the logging lifecycle, not the build session. + */ +@Named +@Singleton +public class DefaultOutputCapabilities implements OutputCapabilities { + public static final String REQUEST_DATA_KEY = "maven.logging.outputCapabilities"; + + public static final OutputCapabilities UNKNOWN = snapshot(Destination.UNKNOWN, null); + + private volatile Snapshot current = (Snapshot) UNKNOWN; + + /** Internal access to an immutable description of the current logging configuration. */ + public Map asMap() { + return current.properties; + } + + @Override + public Destination getDestination() { + return current.getDestination(); + } + + @Override + public Optional getEncoding() { + return current.getEncoding(); + } + + /** Creates an immutable description for transport from logging setup to the container. */ + public static OutputCapabilities snapshot(Destination destination, Charset encoding) { + return new Snapshot(Objects.requireNonNull(destination), encoding); + } + + /** + * Installs the current output description and returns its logging-lifecycle cleanup. + * An obsolete cleanup must not overwrite a subsequently installed configuration. + */ + public synchronized AutoCloseable install(OutputCapabilities capabilities) { + Snapshot previous = current; + Snapshot installed = new Snapshot( + capabilities.getDestination(), capabilities.getEncoding().orElse(null)); + current = installed; + return () -> { + synchronized (DefaultOutputCapabilities.this) { + if (current == installed) { + current = previous; + } + } + }; + } + + private static final class Snapshot implements OutputCapabilities { + private final Destination destination; + private final Optional encoding; + private final Map properties; + + private Snapshot(Destination destination, Charset encoding) { + this.destination = destination; + this.encoding = Optional.ofNullable(encoding); + Map values = new LinkedHashMap<>(); + values.put("destination", destination.name()); + if (encoding != null) { + values.put("encoding", encoding.name()); + } + this.properties = Collections.unmodifiableMap(values); + } + + @Override + public Destination getDestination() { + return destination; + } + + @Override + public Optional getEncoding() { + return encoding; + } + } +} diff --git a/maven-core/src/main/resources/META-INF/maven/extension.xml b/maven-core/src/main/resources/META-INF/maven/extension.xml index aa333bcabe55..8dfcbff48876 100644 --- a/maven-core/src/main/resources/META-INF/maven/extension.xml +++ b/maven-core/src/main/resources/META-INF/maven/extension.xml @@ -32,6 +32,7 @@ under the License. org.apache.maven.execution.scope org.apache.maven.graph org.apache.maven.lifecycle + org.apache.maven.logging.* org.apache.maven.model org.apache.maven.monitor org.apache.maven.plugin diff --git a/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java b/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java new file mode 100644 index 000000000000..f530d4bfcc66 --- /dev/null +++ b/maven-core/src/test/java/org/apache/maven/OutputCapabilitiesRequestTest.java @@ -0,0 +1,217 @@ +/* + * 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; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.codehaus.plexus.testing.PlexusTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@PlexusTest +class OutputCapabilitiesRequestTest extends AbstractCoreMavenComponentTestCase { + private static final String KEY = "maven.logging.outputCapabilities"; + + @Inject + private Maven maven; + + @Inject + private DefaultOutputCapabilities capabilities; + + @Override + protected String getProjectsDirectory() { + return "src/test/projects/default-maven"; + } + + @Test + void capturesEveryConfigurationBeforeRepositoryValidation(@TempDir Path directory) throws Exception { + Path invalidRepository = Files.createFile(directory.resolve("repository-file")); + MavenExecutionRequest request = + new DefaultMavenExecutionRequest().setLocalRepositoryPath(invalidRepository.toFile()); + request.getData().put("unrelated", "retained"); + assertNull(request.getData().get(KEY)); + for (Destination destination : Destination.values()) { + for (Charset encoding : Arrays.asList(null, StandardCharsets.UTF_8, Charset.forName("latin1"))) { + Map captured; + try (AutoCloseable cleanup = + capabilities.install(DefaultOutputCapabilities.snapshot(destination, encoding))) { + captured = capabilities.asMap(); + MavenExecutionResult result = maven.execute(request); + assertFalse(result.getExceptions().isEmpty()); + assertSame(captured, request.getData().get(KEY)); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding")); + assertEquals("retained", request.getData().get("unrelated")); + } + assertSame(captured, request.getData().get(KEY)); + assertEquals(destination.name(), captured.get("destination")); + assertEquals(encoding == null ? null : encoding.name(), captured.get("encoding")); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + } + } + } + + @Test + void publishesBeforeLifecycleCallbacksAndParallelReaders() throws Exception { + MavenExecutionRequest request = request("simple"); + assertNull(request.getData().get(KEY)); + RequestObserver observer = observer(); + try (AutoCloseable cleanup = + capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + executeSuccessfully(request); + Map captured = capabilities.asMap(); + assertSame(request, observer.session.getRequest()); + assertSame(captured, observer.captured); + assertNull(observer.resolverMetadata); + assertSame( + captured, + CompletableFuture.supplyAsync(() -> observer.session + .clone() + .getRequest() + .getData() + .get(KEY)) + .get(10, TimeUnit.SECONDS)); + assertEquals("UTF-8", captured.get("encoding")); + } + assertEquals("FILE", observer.captured.get("destination")); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void copiedIndependentAndReusedRequestsCaptureTheirOwnExecution() throws Exception { + MavenExecutionRequest original = request("simple"); + original.getData().put("unrelated", "original"); + Map fileMap; + Map consoleMap; + try (AutoCloseable file = + capabilities.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + executeSuccessfully(original); + fileMap = capabilities.asMap(); + MavenExecutionRequest copy = DefaultMavenExecutionRequest.copy(original); + assertTrue(copy.getData().isEmpty()); + copy.getData().put("unrelated", "copy"); + MavenExecutionRequest independent = request("simple"); + assertNull(independent.getData().get(KEY)); + try (AutoCloseable console = capabilities.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + consoleMap = capabilities.asMap(); + executeSuccessfully(copy); + executeSuccessfully(independent); + assertSame(consoleMap, copy.getData().get(KEY)); + assertSame(consoleMap, independent.getData().get(KEY)); + assertSame(fileMap, original.getData().get(KEY)); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("copy", copy.getData().get("unrelated")); + assertNull(independent.getData().get("unrelated")); + executeSuccessfully(original); + assertSame(consoleMap, original.getData().get(KEY)); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("FILE", fileMap.get("destination")); + } + assertSame(consoleMap, original.getData().get(KEY)); + executeSuccessfully(original); + assertSame(fileMap, original.getData().get(KEY)); + assertEquals("CONSOLE", consoleMap.get("destination")); + assertEquals("ISO-8859-1", consoleMap.get("encoding")); + } + assertSame(fileMap, original.getData().get(KEY)); + executeSuccessfully(original); + assertSame(capabilities.asMap(), original.getData().get(KEY)); + assertEquals("UNKNOWN", capabilities.asMap().get("destination")); + assertFalse(capabilities.asMap().containsKey("encoding")); + assertEquals("original", original.getData().get("unrelated")); + assertEquals("FILE", fileMap.get("destination")); + assertEquals("UTF-8", fileMap.get("encoding")); + assertEquals("CONSOLE", consoleMap.get("destination")); + } + + @Test + void projectDiscoveryFailurePreservesTheCapturedMap() throws Exception { + MavenExecutionRequest request = request("cyclic-reference"); + RequestObserver observer = observer(); + Map captured; + try (AutoCloseable cleanup = capabilities.install( + DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.ISO_8859_1))) { + captured = capabilities.asMap(); + MavenExecutionResult result = maven.execute(request); + assertEquals( + ProjectCycleException.class, result.getExceptions().get(0).getClass()); + assertSame(captured, observer.captured); + assertNull(observer.resolverMetadata); + } + assertSame(captured, request.getData().get(KEY)); + assertEquals("UNKNOWN", captured.get("destination")); + assertEquals("ISO-8859-1", captured.get("encoding")); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + private MavenExecutionRequest request(String project) throws Exception { + return createMavenExecutionRequest(getProject(project)).setGoals(Collections.singletonList("validate")); + } + + private void executeSuccessfully(MavenExecutionRequest request) { + assertEquals(Collections.emptyList(), maven.execute(request).getExceptions()); + } + + private RequestObserver observer() throws Exception { + return (RequestObserver) + container.lookup(AbstractMavenLifecycleParticipant.class, "output-capabilities-request"); + } + + @Named("output-capabilities-request") + @Singleton + private static final class RequestObserver extends AbstractMavenLifecycleParticipant { + private MavenSession session; + private Map captured; + private Object resolverMetadata; + + @Override + public void afterSessionStart(MavenSession session) { + this.session = session; + captured = (Map) session.getRequest().getData().get(KEY); + resolverMetadata = session.getRepositorySession().getData().get(KEY); + } + } +} diff --git a/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java b/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java new file mode 100644 index 000000000000..8c3cca0dad66 --- /dev/null +++ b/maven-core/src/test/java/org/apache/maven/logging/internal/DefaultOutputCapabilitiesTest.java @@ -0,0 +1,98 @@ +/* + * 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.logging.internal; + +import javax.inject.Inject; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.codehaus.plexus.PlexusContainer; +import org.codehaus.plexus.testing.PlexusTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +@PlexusTest +class DefaultOutputCapabilitiesTest { + @Inject + private OutputCapabilities capabilities; + + @Inject + private PlexusContainer container; + + @Test + void injectableSingletonWithoutSession() throws Exception { + assertSame(capabilities, container.lookup(OutputCapabilities.class)); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void updatesAreVisibleToParallelClientsAndRestoredOnClose() throws Exception { + DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + assertEquals( + Destination.FILE, + CompletableFuture.supplyAsync(capabilities::getDestination).get(10, TimeUnit.SECONDS)); + assertEquals( + Optional.of(StandardCharsets.UTF_8), + CompletableFuture.supplyAsync(capabilities::getEncoding).get(10, TimeUnit.SECONDS)); + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + assertEquals(Destination.CONSOLE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.ISO_8859_1), capabilities.getEncoding()); + } + assertEquals(Destination.FILE, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + } + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void unknownDestinationCanHaveKnownEncoding() throws Exception { + try (AutoCloseable cleanup = ((DefaultOutputCapabilities) capabilities) + .install(DefaultOutputCapabilities.snapshot(Destination.UNKNOWN, StandardCharsets.UTF_8))) { + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding()); + } + } + + @Test + void repeatedCleanupDoesNotResetNextInvocation() throws Exception { + DefaultOutputCapabilities component = (DefaultOutputCapabilities) capabilities; + AutoCloseable first = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8)); + first.close(); + try (AutoCloseable second = component.install( + DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) { + first.close(); + assertEquals(Destination.REDIRECTED, capabilities.getDestination()); + assertEquals(Optional.of(StandardCharsets.US_ASCII), capabilities.getEncoding()); + } + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + } +} diff --git a/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java b/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java new file mode 100644 index 000000000000..bdf69afb43e1 --- /dev/null +++ b/maven-core/src/test/java/org/apache/maven/logging/internal/OutputCapabilitiesMapTest.java @@ -0,0 +1,183 @@ +/* + * 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.logging.internal; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OutputCapabilitiesMapTest { + private final DefaultOutputCapabilities component = new DefaultOutputCapabilities(); + + @Test + void destinationsAndCanonicalEncodingAreIndependent() throws Exception { + Map initial = component.asMap(); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + for (Destination destination : Destination.values()) { + try (AutoCloseable cleanup = component.install(DefaultOutputCapabilities.snapshot(destination, null))) { + assertEquals(Collections.singletonMap("destination", destination.name()), component.asMap()); + assertFalse(component.asMap().containsKey("encoding")); + } + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(destination, Charset.forName("latin1")))) { + Map captured = component.asMap(); + assertEquals(destination.name(), captured.get("destination")); + assertEquals("ISO-8859-1", captured.get("encoding")); + assertEquals(2, captured.size()); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + } + } + assertSame(initial, component.asMap()); + } + + @Test + void retainedMapsAndCollectionsSurviveReconfigurationAndCleanup() throws Exception { + Map initial = component.asMap(); + Map file; + Set> entries; + Set keys; + Collection values; + Iterator> iterator; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + file = component.asMap(); + entries = file.entrySet(); + keys = file.keySet(); + values = file.values(); + iterator = entries.iterator(); + assertNotSame(initial, file); + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + assertEquals("CONSOLE", component.asMap().get("destination")); + assertEquals("ISO-8859-1", component.asMap().get("encoding")); + assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values)); + } + assertSame(file, component.asMap()); + } + assertSame(initial, component.asMap()); + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), initial); + assertEquals("FILE", file.get("destination")); + assertEquals("UTF-8", file.get("encoding")); + assertEquals(file.entrySet(), entries); + assertEquals(2, entries.size()); + assertEquals(Arrays.asList("destination", "encoding"), new ArrayList<>(keys)); + assertEquals(Arrays.asList("FILE", "UTF-8"), new ArrayList<>(values)); + Map fromIterator = new HashMap<>(); + iterator.forEachRemaining(entry -> fromIterator.put(entry.getKey(), entry.getValue())); + assertEquals(file, fromIterator); + } + + @Test + void mutationIsRejectedThroughMapCollectionsAndEntries() throws Exception { + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + Map captured = component.asMap(); + assertThrows(UnsupportedOperationException.class, () -> captured.put("destination", "CONSOLE")); + assertThrows( + UnsupportedOperationException.class, () -> captured.compute("encoding", (key, value) -> "ASCII")); + assertThrows( + UnsupportedOperationException.class, + () -> captured.entrySet().iterator().next().setValue("changed")); + for (Collection collection : Arrays.asList(captured.entrySet(), captured.keySet(), captured.values())) { + assertThrows(UnsupportedOperationException.class, collection::clear); + assertThrows( + UnsupportedOperationException.class, + () -> collection.remove(collection.iterator().next())); + Iterator iterator = collection.iterator(); + iterator.next(); + assertThrows(UnsupportedOperationException.class, iterator::remove); + } + } + } + + @Test + void failureAndRepeatedCleanupDoNotChangeCapturedMaps() throws Exception { + AutoCloseable first = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8)); + Map file = component.asMap(); + first.close(); + Map redirected; + List> failed = new ArrayList<>(); + try (AutoCloseable second = component.install( + DefaultOutputCapabilities.snapshot(Destination.REDIRECTED, StandardCharsets.US_ASCII))) { + redirected = component.asMap(); + first.close(); + assertSame(redirected, component.asMap()); + assertThrows(IllegalStateException.class, () -> { + try (AutoCloseable nested = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + failed.add(component.asMap()); + throw new IllegalStateException("logging setup failed"); + } + }); + assertSame(redirected, component.asMap()); + } + assertEquals(Collections.singletonMap("destination", "UNKNOWN"), component.asMap()); + assertEquals("FILE", file.get("destination")); + assertEquals("UTF-8", file.get("encoding")); + assertEquals("REDIRECTED", redirected.get("destination")); + assertEquals("US-ASCII", redirected.get("encoding")); + assertEquals("CONSOLE", failed.get(0).get("destination")); + assertEquals("ISO-8859-1", failed.get(0).get("encoding")); + } + + @Test + void parallelReadersRetainTheirOriginalInformation() throws Exception { + Map file; + try (AutoCloseable cleanup = + component.install(DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8))) { + file = component.asMap(); + } + CompletableFuture ready = new CompletableFuture<>(); + List>> readers = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + readers.add(ready.thenApplyAsync(ignored -> new HashMap<>(file))); + } + try (AutoCloseable cleanup = component.install( + DefaultOutputCapabilities.snapshot(Destination.CONSOLE, StandardCharsets.ISO_8859_1))) { + ready.complete(null); + for (CompletableFuture> reader : readers) { + Map copy = reader.get(10, TimeUnit.SECONDS); + assertEquals("FILE", copy.get("destination")); + assertEquals("UTF-8", copy.get("encoding")); + } + assertEquals("CONSOLE", component.asMap().get("destination")); + assertEquals("ISO-8859-1", component.asMap().get("encoding")); + } + } +} diff --git a/maven-embedder/src/main/java/org/apache/maven/cli/CliRequest.java b/maven-embedder/src/main/java/org/apache/maven/cli/CliRequest.java index 15439def8dfc..060f5b7bd767 100644 --- a/maven-embedder/src/main/java/org/apache/maven/cli/CliRequest.java +++ b/maven-embedder/src/main/java/org/apache/maven/cli/CliRequest.java @@ -20,10 +20,13 @@ import java.io.File; import java.util.Properties; +import java.util.function.Supplier; import org.apache.commons.cli.CommandLine; import org.apache.maven.execution.DefaultMavenExecutionRequest; import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.codehaus.plexus.classworlds.ClassWorld; /** @@ -52,6 +55,10 @@ public class CliRequest { MavenExecutionRequest request; + Supplier outputCapabilities = () -> DefaultOutputCapabilities.UNKNOWN; + + AutoCloseable outputCapabilitiesCleanup; + CliRequest(String[] args, ClassWorld classWorld) { this.args = args; this.classWorld = classWorld; diff --git a/maven-embedder/src/main/java/org/apache/maven/cli/MavenCli.java b/maven-embedder/src/main/java/org/apache/maven/cli/MavenCli.java index 75876503858b..de6aef4d21dc 100644 --- a/maven-embedder/src/main/java/org/apache/maven/cli/MavenCli.java +++ b/maven-embedder/src/main/java/org/apache/maven/cli/MavenCli.java @@ -68,6 +68,7 @@ import org.apache.maven.cli.logging.Slf4jConfigurationFactory; import org.apache.maven.cli.logging.Slf4jLoggerManager; import org.apache.maven.cli.logging.Slf4jStdoutLogger; +import org.apache.maven.cli.logging.impl.Slf4jSimpleConfiguration; import org.apache.maven.cli.transfer.ConsoleMavenTransferListener; import org.apache.maven.cli.transfer.QuietMavenTransferListener; import org.apache.maven.cli.transfer.SimplexTransferListener; @@ -87,6 +88,8 @@ import org.apache.maven.extension.internal.CoreExtensionEntry; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; import org.apache.maven.message.MessageBuilder; import org.apache.maven.model.building.ModelProcessor; import org.apache.maven.model.interpolation.ModelInterpolator; @@ -119,6 +122,7 @@ import org.slf4j.ILoggerFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.simple.MavenSlf4jSimpleFriend; import org.sonatype.plexus.components.cipher.DefaultPlexusCipher; import org.sonatype.plexus.components.sec.dispatcher.DefaultSecDispatcher; import org.sonatype.plexus.components.sec.dispatcher.SecDispatcher; @@ -311,8 +315,18 @@ public int doMain(CliRequest cliRequest) { return 1; } finally { - if (localContainer != null) { - localContainer.dispose(); + try { + if (localContainer != null) { + localContainer.dispose(); + } + } finally { + if (cliRequest.outputCapabilitiesCleanup != null) { + try { + cliRequest.outputCapabilitiesCleanup.close(); + } catch (Exception e) { + throw new IllegalStateException("Unable to restore logging capabilities", e); + } + } } } } @@ -548,6 +562,7 @@ void logging(CliRequest cliRequest) { } // LOG STREAMS + PrintStream logFileStream = null; if (cliRequest.commandLine.hasOption(CLIManager.LOG_FILE)) { File logFile = new File(cliRequest.commandLine.getOptionValue(CLIManager.LOG_FILE)); logFile = resolveFile(logFile, cliRequest.workingDirectory); @@ -562,6 +577,7 @@ void logging(CliRequest cliRequest) { PrintStream ps = new PrintStream(new FileOutputStream(logFile)); System.setOut(ps); System.setErr(ps); + logFileStream = ps; } catch (FileNotFoundException e) { // // Ignore @@ -570,6 +586,9 @@ void logging(CliRequest cliRequest) { } slf4jConfiguration.activate(); + cliRequest.outputCapabilities = slf4jConfiguration instanceof Slf4jSimpleConfiguration + ? MavenSlf4jSimpleFriend.outputCapabilities(logFileStream) + : () -> DefaultOutputCapabilities.UNKNOWN; plexusLoggerManager = new Slf4jLoggerManager(); slf4jLogger = slf4jLoggerFactory.getLogger(this.getClass().getName()); @@ -701,6 +720,11 @@ protected void configure() { Thread.currentThread().setContextClassLoader(container.getContainerRealm()); container.setLoggerManager(plexusLoggerManager); + OutputCapabilities capabilities = container.lookup(OutputCapabilities.class); + if (capabilities instanceof DefaultOutputCapabilities) { + cliRequest.outputCapabilitiesCleanup = + ((DefaultOutputCapabilities) capabilities).install(cliRequest.outputCapabilities.get()); + } for (CoreExtensionEntry extension : extensions) { container.discoverComponents( diff --git a/maven-embedder/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java b/maven-embedder/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java new file mode 100644 index 000000000000..3a5cd1c5d4cf --- /dev/null +++ b/maven-embedder/src/main/java/org/apache/maven/logging/internal/TerminalOutputCapabilities.java @@ -0,0 +1,139 @@ +/* + * 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.logging.internal; + +import java.util.List; + +import org.apache.maven.jline.FastTerminal; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Internal terminal attachment detection for the CLI's system-output route. */ +public final class TerminalOutputCapabilities { + private static final Logger LOGGER = LoggerFactory.getLogger(TerminalOutputCapabilities.class); + + private TerminalOutputCapabilities() {} + + public static Destination destination(Terminal terminal) { + try { + if (terminal instanceof FastTerminal) { + LOGGER.debug("Resolving asynchronous terminal for logging output detection"); + terminal = ((FastTerminal) terminal).getTerminal(); + } + if (!(terminal instanceof TerminalExt)) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal {} does not expose its provider and system stream", + terminal == null ? null : terminal.getClass().getName()); + return Destination.UNKNOWN; + } + TerminalExt extended = (TerminalExt) terminal; + SystemStream stream = extended.getSystemStream(); + TerminalProvider provider = extended.getProvider(); + LOGGER.debug( + "Inspecting logging output: terminal={}, provider={}, stream={}", + terminal.getClass().getName(), + provider == null ? null : provider.getClass().getName(), + stream); + if (provider != null && !(provider instanceof DumbTerminalProvider)) { + // A non-system terminal may be backed by arbitrary embedder streams. + if (stream == null) { + LOGGER.debug("Logging destination is UNKNOWN: terminal provider has no system stream"); + return Destination.UNKNOWN; + } + return probe(provider, stream); + } + if (!(provider instanceof DumbTerminalProvider)) { + LOGGER.debug("Logging destination is UNKNOWN: terminal has no provider"); + return Destination.UNKNOWN; + } + // A dumb terminal can also result from redirected stdin or failed terminal + // creation. Ask the configured providers about output, not terminal type. + SystemStream outputStream = stream != null ? stream : SystemStream.Output; + String configuredProviders = System.getProperty(TerminalBuilder.PROP_PROVIDER); + LOGGER.debug( + "Dumb terminal provider: probing configured providers {} for logging stream {}", + configuredProviders, + outputStream); + IllegalStateException failure = new IllegalStateException("Unable to inspect terminal output"); + List providers = TerminalBuilder.builder().getProviders(configuredProviders, failure); + if (failure.getSuppressed().length > 0) { + LOGGER.debug("Some terminal providers could not be loaded for logging output detection", failure); + } + return probe(providers, outputStream); + } catch (RuntimeException | LinkageError e) { + LOGGER.debug("Logging destination is UNKNOWN: terminal output detection failed", e); + return Destination.UNKNOWN; + } + } + + static Destination probe(List providers, SystemStream stream) { + boolean redirected = false; + for (TerminalProvider provider : providers) { + if (provider instanceof DumbTerminalProvider) { + LOGGER.debug("Skipping dumb terminal provider when probing logging stream {}", stream); + continue; + } + Destination result = probe(provider, stream); + if (result == Destination.CONSOLE) { + return result; + } + redirected |= result == Destination.REDIRECTED; + } + Destination result = redirected ? Destination.REDIRECTED : Destination.UNKNOWN; + LOGGER.debug("Logging destination for stream {} after probing configured providers: {}", stream, result); + return result; + } + + private static Destination probe(TerminalProvider provider, SystemStream stream) { + String providerClass = provider == null ? null : provider.getClass().getName(); + try { + Destination result; + if (provider.isSystemStream(stream)) { + result = Destination.CONSOLE; + } else if (provider instanceof ExecTerminalProvider) { + // The exec provider also returns false when its external probe fails. + // A negative result cannot distinguish redirection from failed detection. + LOGGER.debug( + "Logging destination is UNKNOWN: exec terminal provider {} returned an inconclusive negative result for stream {}", + providerClass, + stream); + return Destination.UNKNOWN; + } else { + result = Destination.REDIRECTED; + } + LOGGER.debug("Terminal provider {} reports logging stream {} as {}", providerClass, stream, result); + return result; + } catch (RuntimeException | LinkageError e) { + LOGGER.debug( + "Logging destination is UNKNOWN: terminal provider {} failed to inspect stream {}", + providerClass, + stream, + e); + return Destination.UNKNOWN; + } + } +} diff --git a/maven-embedder/src/main/java/org/slf4j/simple/MavenSlf4jSimpleFriend.java b/maven-embedder/src/main/java/org/slf4j/simple/MavenSlf4jSimpleFriend.java index e27d85f003e7..49379799b997 100644 --- a/maven-embedder/src/main/java/org/slf4j/simple/MavenSlf4jSimpleFriend.java +++ b/maven-embedder/src/main/java/org/slf4j/simple/MavenSlf4jSimpleFriend.java @@ -18,6 +18,18 @@ */ package org.slf4j.simple; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.util.function.Supplier; + +import org.apache.maven.jline.FastTerminal; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.apache.maven.logging.internal.DefaultOutputCapabilities; +import org.apache.maven.logging.internal.TerminalOutputCapabilities; +import org.jline.jansi.AnsiConsole; +import org.jline.terminal.Terminal; import org.slf4j.ILoggerFactory; import org.slf4j.LoggerFactory; @@ -26,6 +38,49 @@ * Use with precaution, since this is not normally intended for production use. */ public class MavenSlf4jSimpleFriend { + /** + * Captures the activated provider, including file-open fallback and cached streams. + * Terminal inspection is deferred until container initialization: waiting here would + * change which stream receives output printed during asynchronous Jansi installation. + */ + public static Supplier outputCapabilities(PrintStream logFileStream) { + OutputChoice choice = SimpleLogger.CONFIG_PARAMS.outputChoice; + if (choice == null) { + return () -> DefaultOutputCapabilities.UNKNOWN; + } + PrintStream stream = choice.getTargetPrintStream(); + if (choice.outputChoiceType == OutputChoice.OutputChoiceType.FILE + || (logFileStream != null && stream == logFileStream)) { + OutputCapabilities capabilities = + DefaultOutputCapabilities.snapshot(Destination.FILE, Charset.defaultCharset()); + return () -> capabilities; + } + Terminal terminal = MessageUtils.getTerminal(); + return () -> { + try { + if (terminal != null) { + // Resolving a FastTerminal also waits for Jansi stream installation. + if (terminal instanceof FastTerminal) { + ((FastTerminal) terminal).getTerminal(); + } + if (AnsiConsole.isInstalled() && (stream == AnsiConsole.out() || stream == AnsiConsole.err())) { + // Both Jansi streams write through terminal.output(), even when SLF4J + // selected System.err. Describe that destination, not the stderr descriptor. + return DefaultOutputCapabilities.snapshot( + TerminalOutputCapabilities.destination(terminal), terminal.encoding()); + } + } + } catch (RuntimeException | LinkageError e) { + // Capability inspection must not introduce a new logging failure. + return DefaultOutputCapabilities.UNKNOWN; + } + // SLF4J can also have cached the original stream before asynchronous Jansi installation + // completed (for example in batch mode). Its encoding need not match the terminal's. + // An arbitrary PrintStream supplied by an embedder does not expose its charset on Java 8. + return DefaultOutputCapabilities.UNKNOWN; + }; + } + public static void init() { SimpleLogger.init(); ILoggerFactory loggerFactory = LoggerFactory.getILoggerFactory(); diff --git a/maven-embedder/src/test/java/org/apache/maven/cli/MavenCliOutputCapabilitiesTest.java b/maven-embedder/src/test/java/org/apache/maven/cli/MavenCliOutputCapabilitiesTest.java new file mode 100644 index 000000000000..a3ff318d1a12 --- /dev/null +++ b/maven-embedder/src/test/java/org/apache/maven/cli/MavenCliOutputCapabilitiesTest.java @@ -0,0 +1,86 @@ +/* + * 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.cli; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MavenCliOutputCapabilitiesTest { + @TempDir + Path directory; + + private PrintStream out; + private PrintStream err; + private Properties properties; + + @BeforeEach + void save() { + out = System.out; + err = System.err; + properties = (Properties) System.getProperties().clone(); + System.setProperty("org.slf4j.simpleLogger.logFile", "System.out"); + } + + @AfterEach + void restore() { + if (System.out != out) { + System.out.close(); + } + System.setOut(out); + System.setErr(err); + System.setProperties(properties); + org.slf4j.simple.MavenSlf4jSimpleFriend.init(); + } + + @Test + void logFileWithForcedColorStillReportsFile() throws Exception { + MavenCli cli = new MavenCli(); + CliRequest request = new CliRequest( + new String[] {"-l", directory.resolve("build.log").toString(), "--color=always"}, null); + cli.cli(request); + cli.logging(request); + assertEquals(Destination.FILE, request.outputCapabilities.get().getDestination()); + assertEquals( + Optional.of(Charset.defaultCharset()), + request.outputCapabilities.get().getEncoding()); + } + + @Test + void failedLogFileDoesNotClaimFile() throws Exception { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + MavenCli cli = new MavenCli(); + CliRequest request = new CliRequest(new String[] {"-l", directory.toString()}, null); + cli.cli(request); + cli.logging(request); + assertEquals(Destination.UNKNOWN, request.outputCapabilities.get().getDestination()); + assertEquals(Optional.empty(), request.outputCapabilities.get().getEncoding()); + } +} diff --git a/maven-embedder/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java b/maven-embedder/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java new file mode 100644 index 000000000000..a4d634107198 --- /dev/null +++ b/maven-embedder/src/test/java/org/apache/maven/logging/internal/ExecTerminalProbe.java @@ -0,0 +1,54 @@ +/* + * 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.logging.internal; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.utils.OSUtils; + +/** Subprocess entry point for testing the real exec provider with an empty PATH. */ +public final class ExecTerminalProbe { + private ExecTerminalProbe() {} + + public static void main(String[] args) throws Exception { + ExecTerminalProvider provider = new ExecTerminalProvider(); + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + try { + Process unexpected = + new ProcessBuilder(OSUtils.TEST_COMMAND, "-t", Integer.toString(stream.ordinal())).start(); + unexpected.destroyForcibly(); + throw new AssertionError("Expected the external terminal probe command to be unavailable"); + } catch (IOException expected) { + // The provider swallows this failure and returns false instead of throwing. + } + if (provider.isSystemStream(stream)) { + throw new AssertionError("Expected an inconclusive negative exec probe for " + stream); + } + Destination destination = TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream); + if (destination != Destination.UNKNOWN) { + throw new AssertionError(stream + ": expected UNKNOWN, got " + destination); + } + } + } +} diff --git a/maven-embedder/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java b/maven-embedder/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java new file mode 100644 index 000000000000..706f67c7968a --- /dev/null +++ b/maven-embedder/src/test/java/org/apache/maven/logging/internal/TerminalOutputCapabilitiesTest.java @@ -0,0 +1,158 @@ +/* + * 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.logging.internal; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminalProvider; +import org.jline.terminal.impl.exec.ExecTerminalProvider; +import org.jline.terminal.spi.SystemStream; +import org.jline.terminal.spi.TerminalExt; +import org.jline.terminal.spi.TerminalProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TerminalOutputCapabilitiesTest { + @TempDir + Path directory; + + @Test + void negativeExecProbesAreInconclusive() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider provider = mock(ExecTerminalProvider.class); + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(stream); + when(provider.isSystemStream(stream)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.CONSOLE, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + when(provider.isSystemStream(stream)).thenReturn(false); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.singletonList(provider), stream)); + } + } + + @Test + void inconclusiveExecProbesDoNotOverrideOtherProviders() { + for (SystemStream stream : Arrays.asList(SystemStream.Output, SystemStream.Error)) { + ExecTerminalProvider exec = mock(ExecTerminalProvider.class); + TerminalProvider other = mock(TerminalProvider.class); + for (boolean console : Arrays.asList(false, true)) { + when(other.isSystemStream(stream)).thenReturn(console); + Destination expected = console ? Destination.CONSOLE : Destination.REDIRECTED; + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(expected, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + when(other.isSystemStream(stream)).thenThrow(new UnsatisfiedLinkError()); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(exec, other), stream)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.probe(Arrays.asList(other, exec), stream)); + } + } + + @Test + @EnabledOnOs({OS.LINUX, OS.MAC}) + void missingExecCommandDoesNotMeanRedirection() throws Exception { + Path emptyPath = Files.createDirectory(directory.resolve("empty-path")); + Path output = directory.resolve("probe.log"); + String java = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath, ExecTerminalProbe.class.getName()); + builder.environment().put("PATH", emptyPath.toString()); + builder.redirectErrorStream(true).redirectOutput(output.toFile()); + Process process = builder.start(); + try { + process.getOutputStream().close(); + boolean exited = process.waitFor(30, TimeUnit.SECONDS); + String diagnostics = new String(Files.readAllBytes(output), StandardCharsets.UTF_8); + assertTrue(exited, "Terminal probe timed out: " + diagnostics); + assertEquals(0, process.exitValue(), diagnostics); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + } + + @Test + void unavailableProvidersDoNotMeanRedirection() { + assertEquals( + Destination.UNKNOWN, TerminalOutputCapabilities.probe(Collections.emptyList(), SystemStream.Output)); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe( + Collections.singletonList(new DumbTerminalProvider()), SystemStream.Output)); + TerminalProvider failing = mock(TerminalProvider.class); + when(failing.isSystemStream(SystemStream.Output)).thenThrow(new UnsatisfiedLinkError()); + assertEquals( + Destination.UNKNOWN, + TerminalOutputCapabilities.probe(Collections.singletonList(failing), SystemStream.Output)); + } + + @Test + void probesTheOutputStreamEvenForDumbTerminalType() { + TerminalExt terminal = mock(TerminalExt.class); + TerminalProvider provider = mock(TerminalProvider.class); + when(terminal.getProvider()).thenReturn(provider); + when(terminal.getSystemStream()).thenReturn(SystemStream.Error); + when(terminal.getType()).thenReturn(Terminal.TYPE_DUMB); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(true); + assertEquals(Destination.CONSOLE, TerminalOutputCapabilities.destination(terminal)); + when(provider.isSystemStream(SystemStream.Error)).thenReturn(false); + assertEquals(Destination.REDIRECTED, TerminalOutputCapabilities.destination(terminal)); + } + + @Test + void customTerminalDoesNotImplyConsole() { + TerminalExt terminal = mock(TerminalExt.class); + when(terminal.getProvider()).thenReturn(mock(TerminalProvider.class)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(terminal)); + assertEquals(Destination.UNKNOWN, TerminalOutputCapabilities.destination(mock(Terminal.class))); + } + + @Test + void oneSuccessfulProviderCanEstablishConsoleAttachment() { + TerminalProvider redirected = mock(TerminalProvider.class); + TerminalProvider console = mock(TerminalProvider.class); + when(console.isSystemStream(SystemStream.Output)).thenReturn(true); + assertEquals( + Destination.CONSOLE, + TerminalOutputCapabilities.probe(Arrays.asList(redirected, console), SystemStream.Output)); + assertEquals( + Destination.REDIRECTED, + TerminalOutputCapabilities.probe(Collections.singletonList(redirected), SystemStream.Output)); + } +} diff --git a/maven-embedder/src/test/java/org/slf4j/simple/MavenSlf4jSimpleFriendTest.java b/maven-embedder/src/test/java/org/slf4j/simple/MavenSlf4jSimpleFriendTest.java new file mode 100644 index 000000000000..e1ef66edb2c8 --- /dev/null +++ b/maven-embedder/src/test/java/org/slf4j/simple/MavenSlf4jSimpleFriendTest.java @@ -0,0 +1,142 @@ +/* + * 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.slf4j.simple; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Supplier; + +import org.apache.maven.jline.FastTerminal; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.logging.OutputCapabilities; +import org.apache.maven.logging.OutputCapabilities.Destination; +import org.jline.terminal.Terminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MavenSlf4jSimpleFriendTest { + @TempDir + Path directory; + + private OutputChoice originalChoice; + private PrintStream originalOut; + private PrintStream originalErr; + private String originalLogFile; + + @BeforeEach + void save() { + originalChoice = SimpleLogger.CONFIG_PARAMS.outputChoice; + originalOut = System.out; + originalErr = System.err; + originalLogFile = System.getProperty(SimpleLogger.LOG_FILE_KEY); + } + + @AfterEach + void restore() { + SimpleLogger.CONFIG_PARAMS.outputChoice = originalChoice; + System.setOut(originalOut); + System.setErr(originalErr); + if (originalLogFile == null) { + System.clearProperty(SimpleLogger.LOG_FILE_KEY); + } else { + System.setProperty(SimpleLogger.LOG_FILE_KEY, originalLogFile); + } + } + + @Test + void providerFileUsesDefaultCharset() throws Exception { + Path file = directory.resolve("provider.log"); + System.setProperty(SimpleLogger.LOG_FILE_KEY, file.toString()); + SimpleLogger.init(); + OutputCapabilities capabilities = + MavenSlf4jSimpleFriend.outputCapabilities(null).get(); + assertEquals(Destination.FILE, capabilities.getDestination()); + assertEquals(Optional.of(Charset.defaultCharset()), capabilities.getEncoding()); + try (PrintStream stream = SimpleLogger.CONFIG_PARAMS.outputChoice.getTargetPrintStream()) { + stream.print("caf\u00e9"); + } + assertArrayEquals("caf\u00e9".getBytes(Charset.defaultCharset()), Files.readAllBytes(file)); + } + + @Test + void failedProviderFileReportsActualFallback() throws Exception { + System.setErr(new PrintStream(new ByteArrayOutputStream())); + System.setProperty(SimpleLogger.LOG_FILE_KEY, directory.toString()); + SimpleLogger.init(); + OutputCapabilities capabilities = + MavenSlf4jSimpleFriend.outputCapabilities(null).get(); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + + @Test + void cachedStreamWinsOverLaterSystemStreamReplacement() throws Exception { + try (PrintStream file = new PrintStream(Files.newOutputStream(directory.resolve("cli.log")))) { + System.setOut(file); + SimpleLogger.CONFIG_PARAMS.outputChoice = new OutputChoice(OutputChoice.OutputChoiceType.CACHED_SYS_OUT); + System.setOut(new PrintStream(new ByteArrayOutputStream())); + assertEquals( + Destination.FILE, + MavenSlf4jSimpleFriend.outputCapabilities(file).get().getDestination()); + } + } + + @Test + void terminalInspectionIsDeferredAndFailureRemainsUnknown() { + Terminal originalTerminal = MessageUtils.getTerminal(); + try { + FastTerminal terminal = mock(FastTerminal.class); + when(terminal.getTerminal()).thenThrow(new IllegalStateException("Terminal unavailable")); + MessageUtils.systemInstall(terminal); + SimpleLogger.CONFIG_PARAMS.outputChoice = new OutputChoice(OutputChoice.OutputChoiceType.SYS_ERR); + + Supplier captured = MavenSlf4jSimpleFriend.outputCapabilities(null); + verifyNoInteractions(terminal); + OutputCapabilities capabilities = captured.get(); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } finally { + MessageUtils.systemInstall(originalTerminal); + } + } + + @Test + void unknownStreamDoesNotInheritJvmEncoding() throws Exception { + try (PrintStream stream = new PrintStream(new ByteArrayOutputStream(), true, "UTF-16LE")) { + System.setErr(stream); + SimpleLogger.CONFIG_PARAMS.outputChoice = new OutputChoice(OutputChoice.OutputChoiceType.SYS_ERR); + OutputCapabilities capabilities = + MavenSlf4jSimpleFriend.outputCapabilities(null).get(); + assertEquals(Destination.UNKNOWN, capabilities.getDestination()); + assertEquals(Optional.empty(), capabilities.getEncoding()); + } + } +}