// 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. * *

* 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. * *

* 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 progressPair = matchProgress(message); if (progressPair != null) { progress(progressPair.getFirst(), progressPair.getSecond()); } else { progress("INFO: ", message); } break; } case FINISH: { String message = event.getMessage(); Pair 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 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 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); } } }