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()); + } + } +}