diff options
Diffstat (limited to 'src/main/java/com/google/devtools/build/lib/runtime/FancyTerminalEventHandler.java')
-rw-r--r-- | src/main/java/com/google/devtools/build/lib/runtime/FancyTerminalEventHandler.java | 355 |
1 files changed, 355 insertions, 0 deletions
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/FancyTerminalEventHandler.java b/src/main/java/com/google/devtools/build/lib/runtime/FancyTerminalEventHandler.java new file mode 100644 index 0000000000..e55ad2f244 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/runtime/FancyTerminalEventHandler.java @@ -0,0 +1,355 @@ +// Copyright 2014 Google Inc. All rights reserved. +// +// 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 com.google.devtools.build.lib.runtime; + +import com.google.common.base.Splitter; +import com.google.devtools.build.lib.events.Event; +import com.google.devtools.build.lib.util.Pair; +import com.google.devtools.build.lib.util.io.AnsiTerminal; +import com.google.devtools.build.lib.util.io.OutErr; + +import java.io.IOException; +import java.util.Iterator; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * An event handler for ANSI terminals which uses control characters to + * provide eye-candy, reduce scrolling, and generally improve usability + * for users running directly from the shell. + * + * <p/> + * This event handler differs from a normal terminal because it only adds + * control characters to stderr, not stdout. All blaze status feedback + * is sent to stderr, so adding control characters just to that stream gives + * the benefits described above without modifying the normal output stream. + * For commands like build that don't generate stdout output this doesn't + * matter, but for commands like query and ide_build_info, inserting these + * control characters in stdout invalidated their output. + * + * <p/> + * The underlying streams may be either line-bufferred or unbuffered. + * Normally each event will write out a sequence of output to a single + * stream, and will end with a newline, which ensures a flush. + * But care is required when outputting incomplete lines, or when mixing + * output between the two different streams (stdout and stderr): + * it may be necessary to explicitly flush the output in those cases. + * However, we also don't want to flush too often; that can lead to + * a choppy UI experience. + */ +public class FancyTerminalEventHandler extends BlazeCommandEventHandler { + private static Logger LOG = Logger.getLogger(FancyTerminalEventHandler.class.getName()); + private static final Pattern progressPattern = Pattern.compile( + // Match strings that look like they start with progress info: + // [42%] Compiling base/base.cc + // [1,442 / 23,476] Compiling base/base.cc + "^\\[(?:(?:\\d\\d?\\d?%)|(?:[\\d+,]+ / [\\d,]+))\\] "); + private static final Splitter LINEBREAK_SPLITTER = Splitter.on('\n'); + + private final AnsiTerminal terminal; + + private final boolean useColor; + private final boolean useCursorControls; + private final boolean progressInTermTitle; + public final int terminalWidth; + + private boolean terminalClosed = false; + private boolean previousLineErasable = false; + private int numLinesPreviousErasable = 0; + + public FancyTerminalEventHandler(OutErr outErr, BlazeCommandEventHandler.Options options) { + super(outErr, options); + this.terminal = new AnsiTerminal(outErr.getErrorStream()); + this.terminalWidth = (options.terminalColumns > 0 ? options.terminalColumns : 80); + useColor = options.useColor(); + useCursorControls = options.useCursorControl(); + progressInTermTitle = options.progressInTermTitle; + } + + @Override + public void handle(Event event) { + if (terminalClosed) { + return; + } + if (!eventMask.contains(event.getKind())) { + return; + } + + try { + boolean previousLineErased = false; + if (previousLineErasable) { + previousLineErased = maybeOverwritePreviousMessage(); + } + switch (event.getKind()) { + case PROGRESS: + case START: + { + String message = event.getMessage(); + Pair<String,String> progressPair = matchProgress(message); + if (progressPair != null) { + progress(progressPair.getFirst(), progressPair.getSecond()); + } else { + progress("INFO: ", message); + } + break; + } + case FINISH: + { + String message = event.getMessage(); + Pair<String,String> progressPair = matchProgress(message); + if (progressPair != null) { + String percentage = progressPair.getFirst(); + String rest = progressPair.getSecond(); + progress(percentage, rest + " DONE"); + } else { + progress("INFO: ", message + " DONE"); + } + break; + } + case PASS: + progress("PASS: ", event.getMessage()); + break; + case INFO: + info(event); + break; + case ERROR: + case FAIL: + case TIMEOUT: + // For errors, scroll the message, so it appears above the status + // line, and highlight the word "ERROR" or "FAIL" in boldface red. + errorOrFail(event); + break; + case WARNING: + // For warnings, highlight the word "Warning" in boldface magenta, + // and scroll it. + warning(event); + break; + case SUBCOMMAND: + subcmd(event); + break; + case STDOUT: + if (previousLineErased) { + terminal.flush(); + } + previousLineErasable = false; + super.handle(event); + // We don't need to flush stdout here, because + // super.handle(event) will take care of that. + break; + case STDERR: + putOutput(event); + break; + default: + // Ignore all other event types. + break; + } + } catch (IOException e) { + // The terminal shouldn't have IO errors, unless the shell is killed, which + // should also kill the blaze client. So this isn't something that should + // occur here; it will show up in the client/server interface as a broken + // pipe. + LOG.warning("Terminal was closed during build: " + e); + terminalClosed = true; + } + } + + /** + * Displays a progress message that may be erased by subsequent messages. + * + * @param prefix a short string such as "[99%] " or "INFO: ", which will be highlighted + * @param rest the remainder of the message; may be multiple lines + */ + private void progress(String prefix, String rest) throws IOException { + previousLineErasable = true; + + if (progressInTermTitle) { + int newlinePos = rest.indexOf('\n'); + if (newlinePos == -1) { + terminal.setTitle(prefix + rest); + } else { + terminal.setTitle(prefix + rest.substring(0, newlinePos)); + } + } + + if (useColor) { + terminal.textGreen(); + } + int prefixWidth = prefix.length(); + terminal.writeString(prefix); + terminal.resetTerminal(); + if (showTimestamp) { + String timestamp = timestamp(); + prefixWidth += timestamp.length(); + terminal.writeString(timestamp); + } + int numLines = 0; + Iterator<String> lines = LINEBREAK_SPLITTER.split(rest).iterator(); + String firstLine = lines.next(); + terminal.writeString(firstLine); + // Subtract one, because when the line length is the same as the terminal + // width, the terminal doesn't line-advance, so we don't want to erase + // two lines. + numLines += (prefixWidth + firstLine.length() - 1) / terminalWidth + 1; + crlf(); + while (lines.hasNext()) { + String line = lines.next(); + terminal.writeString(line); + crlf(); + numLines += (line.length() - 1) / terminalWidth + 1; + } + numLinesPreviousErasable = numLines; + } + + /** + * Try to match a message against the "progress message" pattern. If it + * matches, return the progress percentage, and the rest of the message. + * @param message the message to match + * @return a pair containing the progress percentage, and the rest of the + * progress message, or null if the message isn't a progress message. + */ + private Pair<String,String> matchProgress(String message) { + Matcher m = progressPattern.matcher(message); + if (m.find()) { + return Pair.of(message.substring(0, m.end()), message.substring(m.end())); + } else { + return null; + } + } + + /** + * Send the terminal controls that will put the cursor on the beginning + * of the same line if cursor control is on, or the next line if not. + * @returns True if it did any output; if so, caller is responsible for + * flushing the terminal if needed. + */ + private boolean maybeOverwritePreviousMessage() throws IOException { + if (useCursorControls && numLinesPreviousErasable != 0) { + for (int i = 0; i < numLinesPreviousErasable; i++) { + terminal.cr(); + terminal.cursorUp(1); + terminal.clearLine(); + } + return true; + } else { + return false; + } + } + + private void errorOrFail(Event event) throws IOException { + previousLineErasable = false; + if (useColor) { + terminal.textRed(); + terminal.textBold(); + } + terminal.writeString(event.getKind().toString() + ": "); + if (useColor) { + terminal.resetTerminal(); + } + writeTimestampAndLocation(event); + terminal.writeString(event.getMessage()); + terminal.writeString("."); + crlf(); + } + + private void warning(Event warning) throws IOException { + previousLineErasable = false; + if (useColor) { + terminal.textMagenta(); + } + terminal.writeString("WARNING: "); + terminal.resetTerminal(); + writeTimestampAndLocation(warning); + terminal.writeString(warning.getMessage()); + terminal.writeString("."); + crlf(); + } + + private void info(Event event) throws IOException { + previousLineErasable = false; + if (useColor) { + terminal.textGreen(); + } + terminal.writeString(event.getKind().toString() + ": "); + terminal.resetTerminal(); + writeTimestampAndLocation(event); + terminal.writeString(event.getMessage()); + // No period; info messages often end in '...'. + crlf(); + } + + private void subcmd(Event subcmd) throws IOException { + previousLineErasable = false; + if (useColor) { + terminal.textBlue(); + } + terminal.writeString(">>>>> "); + terminal.resetTerminal(); + writeTimestampAndLocation(subcmd); + terminal.writeString(subcmd.getMessage()); + crlf(); + } + + /* Handle STDERR events. */ + private void putOutput(Event event) throws IOException { + previousLineErasable = false; + terminal.writeBytes(event.getMessageBytes()); +/* + * The following code doesn't work because buildtool.TerminalTestNotifier + * writes ANSI-formatted text via this mechanism, one character at a time, + * and if we try to insert additional ANSI sequences in between the characters + * of another ANSI escape sequence, we screw things up. (?) + * TODO(bazel-team): (2009) fix this. TerminalTestNotifier should go via the Reporter + * rather than via an AnsiTerminalWriter. + */ +// terminal.resetTerminal(); +// writeTimestampAndLocation(event); +// if (useColor) { +// terminal.textNormal(); +// } +// terminal.writeBytes(event.getMessageBytes()); +// terminal.resetTerminal(); + } + + /** + * Add a carriage return, shifting to the next line on the terminal, while + * guaranteeing that the terminal control codes don't cause any strange + * effects. Without the CR before the "\n", the "\n" can cause a line-break + * moving text to the next line, where the new message will be generated. + * Emitting a "CR" before means that the actual terminal controls generated + * here are CR+CR+LF; the double-CR resets the terminal line state, which + * prevents the potentially ugly formatting issue. + */ + private void crlf() throws IOException { + terminal.cr(); + terminal.writeString("\n"); + } + + private void writeTimestampAndLocation(Event event) throws IOException { + if (showTimestamp) { + terminal.writeString(timestamp()); + } + if (event.getLocation() != null) { + terminal.writeString(event.getLocation() + ": "); + } + } + + public void resetTerminal() { + try { + terminal.resetTerminal(); + } catch (IOException e) { + LOG.warning("IO Error writing to user terminal: " + e); + } + } +} |