/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow * @format * @oncall react_native */ 'use strict'; const throttle = require('lodash.throttle'); const readline = require('readline'); const tty = require('tty'); const util = require('util'); type UnderlyingStream = net$Socket | stream$Writable; /** * Clear some text that was previously printed on an interactive stream, * without trailing newline character (so we have to move back to the * beginning of the line). */ function clearStringBackwards(stream: tty.WriteStream, str: string): void { readline.moveCursor(stream, -stream.columns, 0); readline.clearLine(stream, 0); let lineCount = (str.match(/\n/g) || []).length; while (lineCount > 0) { readline.moveCursor(stream, 0, -1); readline.clearLine(stream, 0); --lineCount; } } /** * Cut a string into an array of string of the specific maximum size. A newline * ends a chunk immediately (it's not included in the "." RexExp operator), and * is not included in the result. * When counting we should ignore non-printable characters. In particular the * ANSI escape sequences (regex: /\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?m/) * (Not an exhaustive match, intended to match ANSI color escapes) * https://en.wikipedia.org/wiki/ANSI_escape_code */ function chunkString(str: string, size: number): Array { const ANSI_COLOR = '\x1B\\[([0-9]{1,2}(;[0-9]{1,2})?)?m'; const SKIP_ANSI = `(?:${ANSI_COLOR})*`; return str.match(new RegExp(`(?:${SKIP_ANSI}.){1,${size}}`, 'g')) || []; } /** * Get the stream as a TTY if it effectively looks like a valid TTY. */ function getTTYStream(stream: UnderlyingStream): ?tty.WriteStream { if ( stream instanceof tty.WriteStream && stream.isTTY && stream.columns >= 1 ) { return stream; } return null; } /** * We don't just print things to the console, sometimes we also want to show * and update progress. This utility just ensures the output stays neat: no * missing newlines, no mangled log lines. * * const terminal = Terminal.default; * terminal.status('Updating... 38%'); * terminal.log('warning: Something happened.'); * terminal.status('Updating, done.'); * terminal.persistStatus(); * * The final output: * * warning: Something happened. * Updating, done. * * Without the status feature, we may get a mangled output: * * Updating... 38%warning: Something happened. * Updating, done. * * This is meant to be user-readable and TTY-oriented. We use stdout by default * because it's more about status information than diagnostics/errors (stderr). * * Do not add any higher-level functionality in this class such as "warning" and * "error" printers, as it is not meant for formatting/reporting. It has the * single responsibility of handling status messages. */ class Terminal { _logLines: Array; _nextStatusStr: string; _scheduleUpdate: () => void; _statusStr: string; _stream: UnderlyingStream; constructor(stream: UnderlyingStream) { this._logLines = []; this._nextStatusStr = ''; // $FlowFixMe[method-unbinding] added when improving typing for this parameters this._scheduleUpdate = throttle(this._update, 33); this._statusStr = ''; this._stream = stream; } /** * Clear and write the new status, logging in bulk in-between. Doing this in a * throttled way (in a different tick than the calls to `log()` and * `status()`) prevents us from repeatedly rewriting the status in case * `terminal.log()` is called several times. */ _update(): void { const {_statusStr, _stream} = this; const ttyStream = getTTYStream(_stream); if (_statusStr === this._nextStatusStr && this._logLines.length === 0) { return; } if (ttyStream != null) { clearStringBackwards(ttyStream, _statusStr); } this._logLines.forEach(line => { _stream.write(line); _stream.write('\n'); }); this._logLines = []; if (ttyStream != null) { this._nextStatusStr = chunkString( this._nextStatusStr, ttyStream.columns, ).join('\n'); _stream.write(this._nextStatusStr); } this._statusStr = this._nextStatusStr; } /** * Shows some text that is meant to be overriden later. Return the previous * status that was shown and is no more. Calling `status()` with no argument * removes the status altogether. The status is never shown in a * non-interactive terminal: for example, if the output is redirected to a * file, then we don't care too much about having a progress bar. */ status(format: string, ...args: Array): string { const {_nextStatusStr} = this; this._nextStatusStr = util.format(format, ...args); this._scheduleUpdate(); return _nextStatusStr; } /** * Similar to `console.log`, except it moves the status/progress text out of * the way correctly. In non-interactive terminals this is the same as * `console.log`. */ log(format: string, ...args: Array): void { this._logLines.push(util.format(format, ...args)); this._scheduleUpdate(); } /** * Log the current status and start from scratch. This is useful if the last * status was the last one of a series of updates. */ persistStatus(): void { this.log(this._nextStatusStr); this._nextStatusStr = ''; } flush(): void { // Useful if you're going to start calling console.log/console.error directly // again; otherwise you could end up with mangled output when the queued // update starts writing to stream after a delay. /* $FlowFixMe(>=0.99.0 site=react_native_fb) This comment suppresses an * error found when Flow v0.99 was deployed. To see the error, delete this * comment and run Flow. */ this._scheduleUpdate.flush(); } } module.exports = Terminal;