Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Supplier;

import org.apache.maven.api.ProtoSession;
import org.apache.maven.api.cli.InvokerException;
Expand All @@ -38,6 +39,8 @@
import org.apache.maven.cling.logging.Slf4jConfiguration;
import org.apache.maven.eventspy.internal.EventSpyDispatcher;
import org.apache.maven.logging.BuildEventListener;
import org.apache.maven.logging.OutputCapabilities;
import org.apache.maven.logging.internal.DefaultOutputCapabilities;
import org.jline.terminal.Terminal;
import org.slf4j.ILoggerFactory;

Expand Down Expand Up @@ -90,6 +93,9 @@ public LookupContext(InvokerRequest invokerRequest, boolean containerCapsuleMana
public Terminal terminal;
public Consumer<String> writer;

// Set only by a known writer route; custom listeners and writers remain unknown.
public Supplier<OutputCapabilities> outputCapabilities = () -> DefaultOutputCapabilities.UNKNOWN;

public ContainerCapsule containerCapsule;
public Lookup lookup;
public EventSpyDispatcher eventSpyDispatcher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
Expand Down Expand Up @@ -81,9 +82,12 @@
import org.apache.maven.logging.AsyncDrainWriter;
import org.apache.maven.logging.BuildEventListener;
import org.apache.maven.logging.LoggingOutputStream;
import org.apache.maven.logging.OutputCapabilities.Destination;
import org.apache.maven.logging.ProjectBuildLogAppender;
import org.apache.maven.logging.SimpleBuildEventListener;
import org.apache.maven.logging.api.LogLevelRecorder;
import org.apache.maven.logging.internal.DefaultOutputCapabilities;
import org.apache.maven.logging.internal.TerminalOutputCapabilities;
import org.apache.maven.slf4j.MavenSimpleLogger;
import org.codehaus.plexus.PlexusContainer;
import org.jline.terminal.Terminal;
Expand Down Expand Up @@ -427,10 +431,18 @@ protected Consumer<String> doDetermineWriter(C context) {
PrintWriter printWriter = new PrintWriter(Files.newBufferedWriter(logFile), true);
context.closeables.add(printWriter);
raw = printWriter::println;
context.outputCapabilities =
() -> DefaultOutputCapabilities.snapshot(Destination.FILE, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MavenException("Unable to redirect logging to " + logFile, e);
}
} else {
// Resolve this after terminal initialization, when the container is ready.
context.outputCapabilities = () -> DefaultOutputCapabilities.snapshot(
context.invokerRequest.embedded()
? Destination.UNKNOWN
: TerminalOutputCapabilities.destination(context.terminal),
context.terminal.outputEncoding());
// Given the terminal creation has been offloaded to a different thread,
// do not pass directly the terminal writer
raw = msg -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

import org.apache.maven.api.services.Lookup;
import org.apache.maven.internal.impl.DefaultLookup;
import org.apache.maven.logging.OutputCapabilities;
import org.apache.maven.logging.internal.DefaultOutputCapabilities;
import org.apache.maven.slf4j.MavenLoggerFactory;
import org.codehaus.plexus.DefaultPlexusContainer;

import static java.util.Objects.requireNonNull;
Expand All @@ -46,6 +49,13 @@ public PlexusContainerCapsule(
@Override
public void updateLogging(LookupContext context) {
plexusContainer.getLoggerManager().setThresholds(toPlexusLoggingLevel(context.loggerLevel));
OutputCapabilities capabilities = lookup.lookup(OutputCapabilities.class);
if (capabilities instanceof DefaultOutputCapabilities defaultCapabilities) {
context.closeables.add(defaultCapabilities.install(
context.loggerFactory instanceof MavenLoggerFactory
? context.outputCapabilities.get()
: DefaultOutputCapabilities.UNKNOWN));
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 fastTerminal) {
LOGGER.debug("Resolving asynchronous terminal for logging output detection");
terminal = fastTerminal.getTerminal();
}
if (!(terminal instanceof TerminalExt extended)) {
LOGGER.debug(
"Logging destination is UNKNOWN: terminal {} does not expose its provider and system stream",
terminal == null ? null : terminal.getClass().getName());
return Destination.UNKNOWN;
}
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<TerminalProvider> 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<TerminalProvider> 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.cling.invoker;

import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Consumer;

import org.apache.maven.api.cli.InvokerRequest;
import org.apache.maven.api.cli.ParserRequest;
import org.apache.maven.api.cli.mvn.MavenOptions;
import org.apache.maven.api.services.MavenException;
import org.apache.maven.cling.invoker.mvn.MavenContext;
import org.apache.maven.cling.invoker.mvn.MavenInvoker;
import org.apache.maven.logging.OutputCapabilities;
import org.apache.maven.logging.OutputCapabilities.Destination;
import org.jline.terminal.Terminal;
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.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class LookupInvokerOutputCapabilitiesTest {
@TempDir
Path directory;

@Test
void logFileUsesUtf8AndDrainsBeforeClosing() throws Exception {
Path file = directory.resolve("build.log");
try (MavenContext context = context(Optional.of(file.toString()))) {
Consumer<String> writer = new TestInvoker().writer(context);
OutputCapabilities capabilities = context.outputCapabilities.get();
assertEquals(Destination.FILE, capabilities.getDestination());
assertEquals(Optional.of(StandardCharsets.UTF_8), capabilities.getEncoding());
writer.accept("caf\u00e9");
}
assertArrayEquals(
("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file));
}

@Test
void failedFileDoesNotPublishFileCapabilities() throws Exception {
try (MavenContext context = context(Optional.of(directory.toString()))) {
assertThrows(MavenException.class, () -> new TestInvoker().writer(context));
assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination());
assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding());
}
}

@Test
void embeddedWriterHasKnownEncodingButUnknownDestination() throws Exception {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (MavenContext context = context(Optional.empty())) {
Terminal terminal = mock(Terminal.class);
when(terminal.outputEncoding()).thenReturn(StandardCharsets.ISO_8859_1);
when(terminal.writer())
.thenReturn(new PrintWriter(new OutputStreamWriter(bytes, StandardCharsets.ISO_8859_1)));
context.terminal = terminal;
Consumer<String> writer = new TestInvoker().writer(context);
assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination());
assertEquals(
Optional.of(StandardCharsets.ISO_8859_1),
context.outputCapabilities.get().getEncoding());
writer.accept("caf\u00e9");
}
assertArrayEquals(
("caf\u00e9" + System.lineSeparator()).getBytes(StandardCharsets.ISO_8859_1), bytes.toByteArray());
}

@Test
void customWriterRemainsUnknown() throws Exception {
try (MavenContext context = context(Optional.empty())) {
Consumer<String> custom = ignored -> {};
context.writer = custom;
assertSame(custom, new TestInvoker().writer(context));
assertEquals(Destination.UNKNOWN, context.outputCapabilities.get().getDestination());
assertEquals(Optional.empty(), context.outputCapabilities.get().getEncoding());
}
}

private MavenContext context(Optional<String> logFile) {
InvokerRequest request = mock(InvokerRequest.class);
when(request.cwd()).thenReturn(directory);
when(request.installationDirectory()).thenReturn(directory);
when(request.userHomeDirectory()).thenReturn(directory);
when(request.topDirectory()).thenReturn(directory);
when(request.parserRequest()).thenReturn(mock(ParserRequest.class));
when(request.embedded()).thenReturn(true);
MavenOptions options = mock(MavenOptions.class);
when(options.logFile()).thenReturn(logFile);
return new MavenContext(request, false, options);
}

private static class TestInvoker extends MavenInvoker {
TestInvoker() {
super(ProtoLookup.builder().build(), null);
}

Consumer<String> writer(MavenContext context) {
return determineWriter(context);
}
}
}
Loading
Loading