blob: c57b63cc62f51fb757d0998fd13ea0e46023d5a5 [file] [log] [blame]
/*
* Copyright 2020 the original author or authors.
*
* Licensed 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.mvndaemon.mvnd.common.logging;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import org.fusesource.jansi.internal.CLibrary;
import org.jline.terminal.Size;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.terminal.impl.AbstractPosixTerminal;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.jline.utils.Display;
import org.mvndaemon.mvnd.common.Message;
import org.mvndaemon.mvnd.common.Message.BuildException;
import org.mvndaemon.mvnd.common.Message.BuildStarted;
import org.mvndaemon.mvnd.common.Message.ExecutionFailureEvent;
import org.mvndaemon.mvnd.common.Message.MojoStartedEvent;
import org.mvndaemon.mvnd.common.Message.ProjectEvent;
import org.mvndaemon.mvnd.common.Message.StringMessage;
import org.mvndaemon.mvnd.common.Message.TransferEvent;
import org.mvndaemon.mvnd.common.OsUtils;
/**
* A terminal {@link ClientOutput} based on JLine.
*/
public class TerminalOutput implements ClientOutput {
/**
* The '+' key is used to increase the number of lines displayed per project.
*/
public static final int KEY_PLUS = '+';
/**
* The '-' key is used to decrease the number of lines displayed per project.
*/
public static final int KEY_MINUS = '-';
/**
* The Ctrl+B key switches between the no-buffering / buffering modes.
* In no-buffering mode, the output of concurrent builds will be interleaved and
* each line will be prepended with the module name in order to distinguish them.
* In buffering mode, the list of modules being built is displayed and update
* continuously. In this mode, pressing '+' one or more times will open a rolling
* window for each module with the related output.
*/
public static final int KEY_CTRL_B = 'B' & 0x1f;
/**
* The Ctrl+L key forces a redisplay of the output.
*/
public static final int KEY_CTRL_L = 'L' & 0x1f;
/**
* The Ctrl+M (or enter) switches between full-buffering and module-buffering.
* In the full-buffering mode, all the build output is buffered and displayed
* at the end of the build, while the module-buffering mode will output in
* the terminal the log for a given module once it's finished.
*/
public static final int KEY_CTRL_M = 'M' & 0x1f;
private static final AttributedStyle GREEN_FOREGROUND = new AttributedStyle().foreground(AttributedStyle.GREEN);
private static final AttributedStyle CYAN_FOREGROUND = new AttributedStyle().foreground(AttributedStyle.CYAN);
private final Terminal terminal;
private final Terminal.SignalHandler previousIntHandler;
private final Display display;
private final Map<String, Map<String, TransferEvent>> transfers = new LinkedHashMap<>();
private final ArrayList<ExecutionFailureEvent> failures = new ArrayList<>();
private final LinkedHashMap<String, Project> projects = new LinkedHashMap<>();
private final ClientLog log;
private final Thread reader;
private volatile Exception exception;
private volatile boolean closing;
private final long start;
private final ReadWriteLock readInput = new ReentrantReadWriteLock();
private final boolean dumb;
/** A sink for sending messages back to the daemon */
private volatile Consumer<Message> daemonDispatch;
/** A sink for queuing messages to the main queue */
private volatile Consumer<Message> daemonReceive;
/*
* The following non-final fields are read/written from the main thread only.
* This is guaranteed as follows:
* * The read/write ops are only reachable from accept(Message) and accept(List<Message>)
* * Both of these methods are guarded with "main".equals(Thread.currentThread().getName()) assertion
* Therefore, these fields do not need to be volatile
*/
private String name;
private String daemonId;
private int totalProjects;
/** String format for formatting the number of projects done with padding based on {@link #totalProjects} */
private String projectsDoneFomat;
private int maxThreads;
private String artifactIdFormat;
/** String format for formatting the actual/hidden/max thread counts */
private String threadsFormat;
private int linesPerProject = 0;
private int doneProjects = 0;
private String buildStatus;
private boolean displayDone = false;
private boolean noBuffering;
/**
* {@link Project} is owned by the display loop thread and is accessed only from there. Therefore it does not need
* to be immutable.
*/
static class Project {
final String id;
MojoStartedEvent runningExecution;
final List<String> log = new ArrayList<>();
public Project(String id) {
this.id = id;
}
}
public TerminalOutput(boolean noBuffering, int rollingWindowSize, Path logFile) throws IOException {
this.start = System.currentTimeMillis();
TerminalBuilder builder = TerminalBuilder.builder();
boolean outRedirected = CLibrary.isatty(1) == 0;
if (outRedirected) {
builder.dumb(true);
}
this.terminal = builder.build();
this.dumb = terminal.getType().startsWith("dumb");
this.noBuffering = noBuffering;
this.linesPerProject = rollingWindowSize;
terminal.enterRawMode();
Thread mainThread = Thread.currentThread();
daemonDispatch = m -> {
if (m == Message.BareMessage.CANCEL_BUILD_SINGLETON) {
mainThread.interrupt();
}
};
this.previousIntHandler = terminal.handle(Terminal.Signal.INT,
sig -> daemonDispatch.accept(Message.BareMessage.CANCEL_BUILD_SINGLETON));
this.display = new Display(terminal, false);
this.log = logFile == null ? new MessageCollector() : new FileLog(logFile);
if (!dumb) {
final Thread r = new Thread(this::readInputLoop);
r.setDaemon(true);
r.start();
this.reader = r;
} else {
this.reader = null;
}
}
@Override
public void setDaemonId(String daemonId) {
this.daemonId = daemonId;
}
@Override
public void setDaemonDispatch(Consumer<Message> daemonDispatch) {
this.daemonDispatch = daemonDispatch;
}
@Override
public void setDaemonReceive(Consumer<Message> daemonReceive) {
this.daemonReceive = daemonReceive;
}
@Override
public void accept(Message entry) {
assert "main".equals(Thread.currentThread().getName());
if (doAccept(entry)) {
update();
}
}
@Override
public void accept(List<Message> entries) {
assert "main".equals(Thread.currentThread().getName());
for (Message entry : entries) {
if (!doAccept(entry)) {
return;
}
}
update();
}
private boolean doAccept(Message entry) {
switch (entry.getType()) {
case Message.BUILD_STARTED: {
BuildStarted bs = (BuildStarted) entry;
this.name = bs.getProjectId();
this.totalProjects = bs.getProjectCount();
final int totalProjectsDigits = (int) (Math.log10(totalProjects) + 1);
this.projectsDoneFomat = "%" + totalProjectsDigits + "d";
this.maxThreads = bs.getMaxThreads();
this.artifactIdFormat = "%-" + bs.getArtifactIdDisplayLength() + "s ";
final int maxThreadsDigits = (int) (Math.log10(maxThreads) + 1);
this.threadsFormat = "%" + (maxThreadsDigits * 3 + 2) + "s";
if (maxThreads <= 1 || totalProjects <= 1) {
this.noBuffering = true;
display.update(Collections.emptyList(), 0);
applyNoBuffering();
}
break;
}
case Message.CANCEL_BUILD: {
projects.values().stream().flatMap(p -> p.log.stream()).forEach(log);
clearDisplay();
try {
log.close();
} catch (IOException e1) {
throw new RuntimeException(e1);
}
final AttributedStyle s = new AttributedStyle().bold().foreground(AttributedStyle.RED);
new AttributedString("The build was canceled", s).println(terminal);
terminal.flush();
return false;
}
case Message.BUILD_EXCEPTION: {
final BuildException e = (BuildException) entry;
final String msg;
if ("org.apache.commons.cli.UnrecognizedOptionException".equals(e.getClassName())) {
msg = "Unable to parse command line options: " + e.getMessage();
} else {
msg = e.getClassName() + ": " + e.getMessage();
}
projects.values().stream().flatMap(p -> p.log.stream()).forEach(log);
clearDisplay();
try {
log.close();
} catch (IOException e1) {
throw new RuntimeException(e1);
}
final AttributedStyle s = new AttributedStyle().bold().foreground(AttributedStyle.RED);
new AttributedString(msg, s).println(terminal);
terminal.flush();
return false;
}
case Message.PROJECT_STARTED: {
StringMessage be = (StringMessage) entry;
final String artifactId = be.getMessage();
projects.put(artifactId, new Project(artifactId));
break;
}
case Message.MOJO_STARTED: {
final MojoStartedEvent execution = (MojoStartedEvent) entry;
final Project prj = projects.computeIfAbsent(execution.getArtifactId(), Project::new);
prj.runningExecution = execution;
break;
}
case Message.PROJECT_STOPPED: {
StringMessage be = (StringMessage) entry;
final String artifactId = be.getMessage();
Project prj = projects.remove(artifactId);
if (prj != null) {
prj.log.forEach(log);
}
doneProjects++;
displayDone();
break;
}
case Message.BUILD_STATUS: {
this.buildStatus = ((StringMessage) entry).getMessage();
break;
}
case Message.BUILD_FINISHED: {
projects.values().stream().flatMap(p -> p.log.stream()).forEach(log);
clearDisplay();
try {
log.close();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
terminal.flush();
}
return false;
}
case Message.KEEP_ALIVE: {
break;
}
case Message.DISPLAY: {
Message.StringMessage d = (Message.StringMessage) entry;
clearDisplay();
terminal.writer().printf("%s%n", d.getMessage());
break;
}
case Message.PRINT_OUT: {
Message.StringMessage d = (Message.StringMessage) entry;
clearDisplay();
System.out.printf("%s%n", d.getMessage());
break;
}
case Message.PRINT_ERR: {
Message.StringMessage d = (Message.StringMessage) entry;
clearDisplay();
System.err.printf("%s%n", d.getMessage());
break;
}
case Message.PROMPT: {
Message.Prompt prompt = (Message.Prompt) entry;
if (dumb) {
terminal.writer().println("");
break;
}
readInput.writeLock().lock();
try {
clearDisplay();
terminal.writer().printf("[%s] %s", prompt.getProjectId(), prompt.getMessage());
terminal.flush();
StringBuilder sb = new StringBuilder();
while (true) {
int c = terminal.reader().read();
if (c < 0) {
break;
} else if (c == '\n' || c == '\r') {
terminal.writer().println();
daemonDispatch.accept(prompt.response(sb.toString()));
break;
} else if (c == 127) {
if (sb.length() > 0) {
sb.setLength(sb.length() - 1);
terminal.writer().write("\b \b");
terminal.writer().flush();
}
} else {
terminal.writer().print((char) c);
terminal.writer().flush();
sb.append((char) c);
}
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
readInput.writeLock().unlock();
}
break;
}
case Message.BUILD_LOG_MESSAGE: {
StringMessage sm = (StringMessage) entry;
log.accept(sm.getMessage());
break;
}
case Message.PROJECT_LOG_MESSAGE: {
final ProjectEvent bm = (ProjectEvent) entry;
final Project prj = projects.get(bm.getProjectId());
if (prj == null) {
log.accept(bm.getMessage());
} else if (noBuffering || dumb) {
String msg;
if (maxThreads > 1) {
msg = String.format("[%s] %s", bm.getProjectId(), bm.getMessage());
} else {
msg = bm.getMessage();
}
log.accept(msg);
} else {
prj.log.add(bm.getMessage());
}
break;
}
case Message.KEYBOARD_INPUT: {
char keyStroke = ((StringMessage) entry).getMessage().charAt(0);
switch (keyStroke) {
case KEY_PLUS:
linesPerProject = Math.min(10, linesPerProject + 1);
break;
case KEY_MINUS:
linesPerProject = Math.max(0, linesPerProject - 1);
break;
case KEY_CTRL_B:
noBuffering = !noBuffering;
if (noBuffering) {
applyNoBuffering();
} else {
clearDisplay();
}
break;
case KEY_CTRL_L:
clearDisplay();
break;
case KEY_CTRL_M:
displayDone = !displayDone;
displayDone();
break;
}
break;
}
case Message.TRANSFER_INITIATED:
case Message.TRANSFER_STARTED:
case Message.TRANSFER_PROGRESSED: {
final TransferEvent te = (TransferEvent) entry;
transfers.computeIfAbsent(orEmpty(te.getProjectId()), p -> new LinkedHashMap<>())
.put(te.getResourceName(), te);
break;
}
case Message.TRANSFER_CORRUPTED:
case Message.TRANSFER_SUCCEEDED:
case Message.TRANSFER_FAILED: {
final TransferEvent te = (TransferEvent) entry;
transfers.computeIfAbsent(orEmpty(te.getProjectId()), p -> new LinkedHashMap<>())
.remove(te.getResourceName());
break;
}
case Message.EXECUTION_FAILURE: {
final ExecutionFailureEvent efe = (ExecutionFailureEvent) entry;
failures.add(efe);
break;
}
default:
throw new IllegalStateException("Unexpected message " + entry);
}
return true;
}
private String orEmpty(String s) {
return s != null ? s : "";
}
private void applyNoBuffering() {
projects.values().stream().flatMap(p -> p.log.stream()).forEach(log);
projects.clear();
}
@Override
public void describeTerminal() {
StringBuilder sb = new StringBuilder();
sb.append("Terminal: ").append(terminal != null ? terminal.getClass().getName() : null);
if (terminal instanceof AbstractPosixTerminal) {
sb.append(" with pty ").append(((AbstractPosixTerminal) terminal).getPty().getClass().getName());
}
this.accept(Message.log(sb.toString()));
}
@Override
public int getTerminalWidth() {
return terminal.getWidth();
}
void readInputLoop() {
try {
while (!closing) {
if (readInput.readLock().tryLock(10, TimeUnit.MILLISECONDS)) {
int c = terminal.reader().read(10);
if (c == -1) {
break;
}
if (c == KEY_PLUS || c == KEY_MINUS || c == KEY_CTRL_L || c == KEY_CTRL_M || c == KEY_CTRL_B) {
daemonReceive.accept(Message.keyboardInput((char) c));
}
readInput.readLock().unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (InterruptedIOException e) {
Thread.currentThread().interrupt();
} catch (IOException e) {
this.exception = e;
}
}
private void clearDisplay() {
if (!noBuffering && !dumb) {
display.update(Collections.emptyList(), 0);
}
}
private void displayDone() {
if (displayDone) {
try {
log.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@Override
public void close() throws Exception {
closing = true;
if (reader != null) {
reader.interrupt();
}
log.close();
terminal.handle(Terminal.Signal.INT, previousIntHandler);
terminal.close();
if (exception != null) {
throw exception;
}
}
private void update() {
if (noBuffering || dumb) {
try {
log.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
return;
}
// no need to refresh the display at every single step
final Size size = terminal.getSize();
final int rows = size.getRows();
final int cols = size.getColumns();
display.resize(rows, size.getColumns());
if (rows <= 0) {
clearDisplay();
return;
}
final List<AttributedString> lines = new ArrayList<>(rows);
final int projectsCount = projects.size();
int dispLines = rows;
// status line
dispLines--;
// there's a bug which sometimes make the cursor goes one line below,
// so keep one more line empty at the end
dispLines--;
addStatusLine(lines, dispLines, projectsCount);
AttributedString globalFailure = formatFailures();
if (globalFailure != null) {
lines.add(globalFailure);
dispLines--;
}
AttributedString globalTransfer = formatTransfers("");
if (globalTransfer != null) {
lines.add(globalTransfer);
dispLines--;
}
if (projectsCount <= dispLines) {
int remLogLines = dispLines - projectsCount;
for (Project prj : projects.values()) {
addProjectLine(lines, prj);
// get the last lines of the project log, taking multi-line logs into account
int nb = Math.min(remLogLines, linesPerProject);
List<AttributedString> logs = lastN(prj.log, nb).stream()
.flatMap(s -> AttributedString.fromAnsi(s).columnSplitLength(Integer.MAX_VALUE).stream())
.map(s -> concat(" ", s))
.collect(lastN(nb));
lines.addAll(logs);
remLogLines -= logs.size();
}
while (remLogLines-- > 0 && lines.size() <= maxThreads + 1) {
lines.add(AttributedString.EMPTY);
}
} else {
int skipProjects = projectsCount - dispLines;
for (Project prj : projects.values()) {
if (skipProjects == 0) {
addProjectLine(lines, prj);
} else {
skipProjects--;
}
}
}
List<AttributedString> trimmed = lines.stream()
.map(s -> s.columnSubSequence(0, cols))
.collect(Collectors.toList());
display.update(trimmed, -1);
}
private AttributedString formatFailures() {
if (failures.isEmpty()) {
return null;
}
AttributedStringBuilder asb = new AttributedStringBuilder();
asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).bold());
if (failures.stream().anyMatch(ExecutionFailureEvent::isHalted)) {
asb.append("ABORTING ");
}
asb.append("FAILURE: ");
asb.style(AttributedStyle.DEFAULT.foreground(AttributedStyle.RED));
if (failures.size() == 1) {
ExecutionFailureEvent efe = failures.iterator().next();
asb.append(efe.getProjectId());
String exception = efe.getException();
if (exception != null) {
if (exception.startsWith("org.apache.maven.lifecycle.LifecycleExecutionException: ")) {
exception = exception
.substring("org.apache.maven.lifecycle.LifecycleExecutionException: ".length());
}
asb.append(": ").append(exception);
}
} else {
asb.append(String.valueOf(failures.size())).append(" projects failed: ");
asb.append(failures.stream().map(ExecutionFailureEvent::getProjectId).collect(Collectors.joining(", ")));
}
AttributedString as = asb.toAttributedString();
if (as.columnLength() >= getTerminalWidth() - 1) {
asb = new AttributedStringBuilder();
asb.append(as.columnSubSequence(0, getTerminalWidth() - 2));
asb.style(AttributedStyle.DEFAULT);
asb.append("…");
as = asb.toAttributedString();
}
return as;
}
private AttributedString formatTransfers(String projectId) {
Collection<TransferEvent> transfers = this.transfers.getOrDefault(projectId, Collections.emptyMap()).values();
if (transfers.isEmpty()) {
return null;
}
TransferEvent event = transfers.iterator().next();
String action = event.getRequestType() == TransferEvent.PUT ? "Uploading" : "Downloading";
if (transfers.size() == 1) {
String direction = event.getRequestType() == TransferEvent.PUT ? "to" : "from";
long cur = event.getTransferredBytes();
long max = event.getContentLength();
AttributedStringBuilder asb = new AttributedStringBuilder();
asb.append(action);
asb.append(' ');
asb.style(AttributedStyle.BOLD);
asb.append(pathToMaven(event.getResourceName()));
asb.style(AttributedStyle.DEFAULT);
asb.append(' ');
asb.append(direction);
asb.append(' ');
asb.append(event.getRepositoryId());
if (cur > 0 && cur < max) {
asb.append(' ');
asb.append(OsUtils.bytesTohumanReadable(cur));
asb.append('/');
asb.append(OsUtils.bytesTohumanReadable(max));
}
return asb.toAttributedString();
} else {
return new AttributedString(action + " " + transfers.size() + " files...");
}
}
public static String pathToMaven(String location) {
String[] p = location.split("/");
if (p.length >= 4 && p[p.length - 1].startsWith(p[p.length - 3] + "-" + p[p.length - 2])) {
String artifactId = p[p.length - 3];
String version = p[p.length - 2];
String classifier;
String type;
String artifactIdVersion = artifactId + "-" + version;
StringBuilder sb = new StringBuilder();
if (p[p.length - 1].charAt(artifactIdVersion.length()) == '-') {
classifier = p[p.length - 1].substring(artifactIdVersion.length() + 1, p[p.length - 1].lastIndexOf('.'));
} else {
classifier = null;
}
type = p[p.length - 1].substring(p[p.length - 1].lastIndexOf('.') + 1);
for (int j = 0; j < p.length - 3; j++) {
if (j > 0) {
sb.append('.');
}
sb.append(p[j]);
}
sb.append(':').append(artifactId).append(':').append(version);
if (!"jar".equals(type) || classifier != null) {
sb.append(':');
if (!"jar".equals(type)) {
sb.append(type);
}
if (classifier != null) {
sb.append(':').append(classifier);
}
}
return sb.toString();
}
return location;
}
private void addStatusLine(final List<AttributedString> lines, int dispLines, final int projectsCount) {
if (name != null || buildStatus != null) {
AttributedStringBuilder asb = new AttributedStringBuilder();
if (name != null) {
asb.append("Building ");
asb.style(AttributedStyle.BOLD);
asb.append(name);
asb.style(AttributedStyle.DEFAULT);
/* Daemon ID */
asb
.append(" daemon: ")
.style(AttributedStyle.BOLD)
.append(daemonId)
.style(AttributedStyle.DEFAULT);
/* Threads */
asb
.append(" threads used/hidden/max: ")
.style(AttributedStyle.BOLD)
.append(
String.format(
threadsFormat,
new StringBuilder(threadsFormat.length())
.append(projectsCount)
.append('/')
.append(Math.max(0, projectsCount - dispLines))
.append('/')
.append(maxThreads).toString()))
.style(AttributedStyle.DEFAULT);
/* Progress */
asb
.append(" progress: ")
.style(AttributedStyle.BOLD)
.append(String.format(projectsDoneFomat, doneProjects))
.append('/')
.append(String.valueOf(totalProjects))
.append(' ')
.append(String.format("%3d", doneProjects * 100 / totalProjects))
.append('%')
.style(AttributedStyle.DEFAULT);
} else if (buildStatus != null) {
asb
.style(AttributedStyle.BOLD)
.append(buildStatus)
.style(AttributedStyle.DEFAULT);
}
/* Time */
long sec = (System.currentTimeMillis() - this.start) / 1000;
asb
.append(" time: ")
.style(AttributedStyle.BOLD)
.append(String.format("%02d:%02d", sec / 60, sec % 60))
.style(AttributedStyle.DEFAULT);
lines.add(asb.toAttributedString());
}
}
private void addProjectLine(final List<AttributedString> lines, Project prj) {
final MojoStartedEvent execution = prj.runningExecution;
final AttributedStringBuilder asb = new AttributedStringBuilder();
AttributedString transfer = formatTransfers(prj.id);
if (transfer != null) {
asb
.append(':')
.style(CYAN_FOREGROUND)
.append(String.format(artifactIdFormat, prj.id))
.style(AttributedStyle.DEFAULT)
.append(transfer);
} else if (execution == null) {
asb
.append(':')
.style(CYAN_FOREGROUND)
.append(prj.id);
} else {
asb
.append(':')
.style(CYAN_FOREGROUND)
.append(String.format(artifactIdFormat, prj.id))
.style(GREEN_FOREGROUND)
.append(execution.getPluginArtifactId())
.append(':')
.append(execution.getMojo())
.append(' ')
.style(AttributedStyle.DEFAULT)
.append('(')
.append(execution.getExecutionId())
.append(')');
}
lines.add(asb.toAttributedString());
}
private static <T> List<T> lastN(List<T> list, int n) {
return list.subList(Math.max(0, list.size() - n), list.size());
}
private static <T> Collector<T, ?, List<T>> lastN(int n) {
return Collector.<T, Deque<T>, List<T>> of(ArrayDeque::new, (acc, t) -> {
if (n > 0) {
if (acc.size() == n)
acc.pollFirst();
acc.add(t);
}
}, (acc1, acc2) -> {
while (acc2.size() < n && !acc1.isEmpty()) {
acc2.addFirst(acc1.pollLast());
}
return acc2;
}, ArrayList::new);
}
private static AttributedString concat(String s1, AttributedString s2) {
AttributedStringBuilder asb = new AttributedStringBuilder();
asb.append(s1);
asb.append(s2);
return asb.toAttributedString();
}
/**
* A closeable string message consumer.
*/
interface ClientLog extends Consumer<String> {
void accept(String message);
void flush() throws IOException;
void close() throws IOException;
}
/**
* A {@link ClientLog} backed by a file.
*/
static class FileLog implements ClientLog {
private final Writer out;
private final Path logFile;
public FileLog(Path logFile) throws IOException {
super();
this.out = Files.newBufferedWriter(logFile, StandardCharsets.UTF_8);
this.logFile = logFile;
}
@Override
public void accept(String message) {
try {
out.write(message);
out.write('\n');
} catch (IOException e) {
throw new RuntimeException("Could not write to " + logFile, e);
}
}
@Override
public void flush() throws IOException {
out.flush();
}
@Override
public void close() throws IOException {
out.close();
}
}
/**
* A {@link ClientLog} that first collects all incoming messages in a {@link List} and outputs them to a JLine
* {@link Terminal} upon {@link #close()}.
*/
class MessageCollector implements ClientLog {
private final List<String> messages = new ArrayList<>();
@Override
public void accept(String message) {
messages.add(message);
}
@Override
public void flush() {
clearDisplay();
messages.forEach(terminal.writer()::println);
messages.clear();
terminal.flush();
}
@Override
public void close() {
flush();
}
}
}