|  | // Copyright 2014 The Bazel Authors. 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.common.collect.ImmutableList; | 
|  | import com.google.common.collect.ImmutableSet; | 
|  | import com.google.common.collect.Iterators; | 
|  | import com.google.devtools.build.lib.events.Event; | 
|  | import com.google.devtools.build.lib.events.EventKind; | 
|  | 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.AnsiTerminalWriter; | 
|  | import com.google.devtools.build.lib.util.io.LineCountingAnsiTerminalWriter; | 
|  | import com.google.devtools.build.lib.util.io.LineWrappingAnsiTerminalWriter; | 
|  | import com.google.devtools.build.lib.util.io.OutErr; | 
|  | import com.google.devtools.build.lib.vfs.PathFragment; | 
|  | import java.io.IOException; | 
|  | import java.nio.charset.StandardCharsets; | 
|  | import java.time.Duration; | 
|  | import java.time.Instant; | 
|  | import java.util.Calendar; | 
|  | import java.util.Iterator; | 
|  | import java.util.concurrent.ThreadLocalRandom; | 
|  | 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 final Logger logger = 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 static final ImmutableList<String> SPECIAL_MESSAGES = | 
|  | ImmutableList.of( | 
|  | "Reading startup options from " | 
|  | + "HKEY_LOCAL_MACHINE\\Software\\Google\\Devtools\\CurrentVersion", | 
|  | "Contacting ftp://microsoft.com/win3.1/downloadcenter", | 
|  | "Downloading MSVCR71.DLL", | 
|  | "Installing Windows Update 37 of 118...", | 
|  | "Sending request to Azure server", | 
|  | "Initializing HAL", | 
|  | "Loading NDIS2SUP.VXD", | 
|  | "Initializing DRM", | 
|  | "Contacting license server", | 
|  | "Starting EC2 instances", | 
|  | "Starting MS-DOS 6.0", | 
|  | "Updating virus database", | 
|  | "Linking WIN32.DLL", | 
|  | "Linking GGL32.EXE", | 
|  | "Starting ActiveX controls", | 
|  | "Launching Microsoft Visual Studio 2013", | 
|  | "Launching IEXPLORE.EXE", | 
|  | "Initializing BASIC v2.1 interpreter", | 
|  | "Parsing COM object monikers", | 
|  | "Notifying field agents", | 
|  | "Negotiating with killer robots", | 
|  | "Searching for cellular signal", | 
|  | "Waiting for workstation CPU temperature to decrease"); | 
|  |  | 
|  | private static final ImmutableSet<Character> PUNCTUATION_CHARACTERS = | 
|  | ImmutableSet.<Character>of(',', '.', ':', '?', '!', ';'); | 
|  |  | 
|  | private final Iterator<String> messageIterator = Iterators.cycle(SPECIAL_MESSAGES); | 
|  | private volatile boolean trySpecial; | 
|  | private volatile Instant skipUntil = Instant.now(); | 
|  |  | 
|  | 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, PathFragment workspacePathFragment) { | 
|  | super(outErr, options, workspacePathFragment); | 
|  | this.terminal = new AnsiTerminal(outErr.getErrorStream()); | 
|  | this.terminalWidth = (options.terminalColumns > 0 ? options.terminalColumns : 80); | 
|  | useColor = options.useColor(); | 
|  | useCursorControls = options.useCursorControl(); | 
|  | progressInTermTitle = options.progressInTermTitle; | 
|  |  | 
|  | Calendar today = Calendar.getInstance(); | 
|  | trySpecial = options.forceExternalRepositories | 
|  | || (options.externalRepositories | 
|  | && today.get(Calendar.MONTH) == Calendar.APRIL | 
|  | && today.get(Calendar.DAY_OF_MONTH) == 1); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public synchronized void handle(Event event) { | 
|  | if (terminalClosed) { | 
|  | return; | 
|  | } | 
|  | if (!eventMask.contains(event.getKind())) { | 
|  | handleFollowUpEvents(event); | 
|  | return; | 
|  | } | 
|  | if (trySpecial | 
|  | && !EventKind.ERRORS_AND_WARNINGS_AND_OUTPUT.contains(event.getKind()) | 
|  | && skipUntil.isAfter(Instant.now())) { | 
|  | // Short-circuit here to avoid wiping out previous terminal contents. | 
|  | handleFollowUpEvents(event); | 
|  | 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()); | 
|  | if (trySpecial && ThreadLocalRandom.current().nextInt(0, 20) == 0) { | 
|  | message = getExtraMessage(); | 
|  | if (message != null) { | 
|  | // Should always be true, but don't crash on that! | 
|  | previousLineErased = maybeOverwritePreviousMessage(); | 
|  | progress(progressPair.getFirst(), message); | 
|  | // Skip unimportant messages for a bit so that this message gets some exposure. | 
|  | skipUntil = | 
|  | Instant.now() | 
|  | .plus(Duration.ofMillis(ThreadLocalRandom.current().nextInt(3000, 8000))); | 
|  | } | 
|  | } | 
|  | } 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 DEBUG: | 
|  | // For debug messages, highlight the word "Debug" in boldface yellow, | 
|  | // and scroll it. | 
|  | debug(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. | 
|  | logger.warning("Terminal was closed during build: " + e); | 
|  | terminalClosed = true; | 
|  | } | 
|  | handleFollowUpEvents(event); | 
|  | } | 
|  |  | 
|  | private void handleFollowUpEvents(Event event) { | 
|  | if (event.getStdErr() != null) { | 
|  | handle( | 
|  | Event.of( | 
|  | EventKind.STDERR, null, event.getStdErr().getBytes(StandardCharsets.ISO_8859_1))); | 
|  | } | 
|  | if (event.getStdOut() != null) { | 
|  | handle( | 
|  | Event.of( | 
|  | EventKind.STDOUT, null, event.getStdOut().getBytes(StandardCharsets.ISO_8859_1))); | 
|  | } | 
|  | } | 
|  |  | 
|  | private String getExtraMessage() { | 
|  | synchronized (messageIterator) { | 
|  | if (messageIterator.hasNext()) { | 
|  | return messageIterator.next(); | 
|  | } | 
|  | } | 
|  | trySpecial = false; | 
|  | return null; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * 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)); | 
|  | } | 
|  | } | 
|  |  | 
|  | LineCountingAnsiTerminalWriter countingWriter = new LineCountingAnsiTerminalWriter(terminal); | 
|  | AnsiTerminalWriter terminalWriter = countingWriter; | 
|  |  | 
|  | if (useCursorControls) { | 
|  | terminalWriter = new LineWrappingAnsiTerminalWriter(terminalWriter, terminalWidth - 1); | 
|  | } | 
|  |  | 
|  | if (useColor) { | 
|  | terminalWriter.okStatus(); | 
|  | } | 
|  | terminalWriter.append(prefix); | 
|  | terminalWriter.normal(); | 
|  | if (showTimestamp) { | 
|  | String timestamp = timestamp(); | 
|  | terminalWriter.append(timestamp); | 
|  | } | 
|  | Iterator<String> lines = LINEBREAK_SPLITTER.split(rest).iterator(); | 
|  | String firstLine = lines.next(); | 
|  | terminalWriter.append(firstLine); | 
|  | terminalWriter.newline(); | 
|  | while (lines.hasNext()) { | 
|  | String line = lines.next(); | 
|  | terminalWriter.append(line); | 
|  | terminalWriter.newline(); | 
|  | } | 
|  | numLinesPreviousErasable = countingWriter.getWrittenLines(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * 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. | 
|  | * @return 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() + ": "); | 
|  | if (useColor) { | 
|  | terminal.resetTerminal(); | 
|  | } | 
|  | writeTimestampAndLocation(event); | 
|  | writeStringWithPotentialPeriod(event.getMessage()); | 
|  | crlf(); | 
|  | } | 
|  |  | 
|  | private void warning(Event warning) throws IOException { | 
|  | previousLineErasable = false; | 
|  | if (useColor) { | 
|  | terminal.textMagenta(); | 
|  | } | 
|  | terminal.writeString("WARNING: "); | 
|  | terminal.resetTerminal(); | 
|  | writeTimestampAndLocation(warning); | 
|  | writeStringWithPotentialPeriod(warning.getMessage()); | 
|  | crlf(); | 
|  | } | 
|  |  | 
|  | private void info(Event event) throws IOException { | 
|  | previousLineErasable = false; | 
|  | if (useColor) { | 
|  | terminal.textGreen(); | 
|  | } | 
|  | terminal.writeString(event.getKind() + ": "); | 
|  | terminal.resetTerminal(); | 
|  | writeTimestampAndLocation(event); | 
|  | terminal.writeString(event.getMessage()); | 
|  | // No period; info messages may end with a URL. | 
|  | crlf(); | 
|  | } | 
|  |  | 
|  | private void debug(Event debug) throws IOException { | 
|  | previousLineErasable = false; | 
|  | if (useColor) { | 
|  | terminal.textYellow(); | 
|  | } | 
|  | terminal.writeString("DEBUG: "); | 
|  | terminal.resetTerminal(); | 
|  | writeTimestampAndLocation(debug); | 
|  | writeStringWithPotentialPeriod(debug.getMessage()); | 
|  | crlf(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Writes the given String to the terminal. This method also writes a trailing period if the | 
|  | * message doesn't end with a punctuation character. | 
|  | */ | 
|  | private void writeStringWithPotentialPeriod(String message) throws IOException { | 
|  | terminal.writeString(message); | 
|  | if (!message.isEmpty()) { | 
|  | char lastChar = message.charAt(message.length() - 1); | 
|  | if (!PUNCTUATION_CHARACTERS.contains(lastChar)) { | 
|  | terminal.writeString("."); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | 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(locationPrinter.getLocationString(event.getLocation()) + ": "); | 
|  | } | 
|  | } | 
|  |  | 
|  | public void resetTerminal() { | 
|  | try { | 
|  | terminal.resetTerminal(); | 
|  | } catch (IOException e) { | 
|  | logger.warning("IO Error writing to user terminal: " + e); | 
|  | } | 
|  | } | 
|  | } |