diff --git a/doc/api/readline.md b/doc/api/readline.md index 8f372a8473e06d..ff4402bed31258 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -521,6 +521,478 @@ added: v0.7.7 The `readline.moveCursor()` method moves the cursor *relative* to its current position in a given [TTY][] `stream`. +## Readline Promises API + +> Stability: 1 - Experimental + +The `readline.promises` API provides an alternative set of methods that return +`Promise` objects rather than using callbacks. The API is accessible via +`require('readline').promises`. + +### Class: readline.promises.Interface + + +Instances of the `readline.promises.Interface` class are constructed using the +`readline.promises.createInterface()` method. Every instance is associated with +a single `input` [Readable][] stream and a single `output` [Writable][] stream. +The `output` stream is used to print prompts for user input that arrives on, +and is read from, the `input` stream. + +#### Event: 'close' + + +The `'close'` event is emitted when one of the following occur: + +* The `rl.close()` method is called and the `readline.promises.Interface` + instance has relinquished control over the `input` and `output` streams; +* The `input` stream receives its `'end'` event; +* The `input` stream receives `-D` to signal end-of-transmission (EOT); +* The `input` stream receives `-C` to signal `SIGINT` and there is no + `'SIGINT'` event listener registered on the `readline.promises.Interface` + instance. + +The listener function is called without passing any arguments. + +The `readline.promises.Interface` instance is finished once the `'close'` event +is emitted. + +#### Event: 'line' + + +The `'line'` event is emitted whenever the `input` stream receives an +end-of-line input (`\n`, `\r`, or `\r\n`). This usually occurs when the user +presses the ``, or `` keys. + +The listener function is called with a string containing the single line of +received input. + +```js +rl.on('line', (input) => { + console.log(`Received: ${input}`); +}); +``` + +#### Event: 'pause' + + +The `'pause'` event is emitted when one of the following occur: + +* The `input` stream is paused. +* The `input` stream is not paused and receives the `'SIGCONT'` event. (See + events [`'SIGTSTP'`][] and [`'SIGCONT'`][].) + +The listener function is called without passing any arguments. + +```js +rl.on('pause', () => { + console.log('Readline paused.'); +}); +``` + +#### Event: 'resume' + + +The `'resume'` event is emitted whenever the `input` stream is resumed. + +The listener function is called without passing any arguments. + +```js +rl.on('resume', () => { + console.log('Readline resumed.'); +}); +``` + +#### Event: 'SIGCONT' + + +The `'SIGCONT'` event is emitted when a Node.js process previously moved into +the background using `-Z` (i.e. `SIGTSTP`) is then brought back to the +foreground using fg(1p). + +If the `input` stream was paused *before* the `SIGTSTP` request, this event will +not be emitted. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGCONT', () => { + // `prompt` will automatically resume the stream + rl.prompt(); +}); +``` + +The `'SIGCONT'` event is _not_ supported on Windows. + +#### Event: 'SIGINT' + + +The `'SIGINT'` event is emitted whenever the `input` stream receives a +`-C` input, known typically as `SIGINT`. If there are no `'SIGINT'` event +listeners registered when the `input` stream receives a `SIGINT`, the `'pause'` +event will be emitted. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGINT', async () => { + const answer = await rl.question('Are you sure you want to exit? '); + + if (answer.match(/^y(es)?$/i)) rl.pause(); +}); +``` + +#### Event: 'SIGTSTP' + + +The `'SIGTSTP'` event is emitted when the `input` stream receives a `-Z` +input, typically known as `SIGTSTP`. If there are no `'SIGTSTP'` event listeners +registered when the `input` stream receives a `SIGTSTP`, the Node.js process +will be sent to the background. + +When the program is resumed using fg(1p), the `'pause'` and `'SIGCONT'` events +will be emitted. These can be used to resume the `input` stream. + +The `'pause'` and `'SIGCONT'` events will not be emitted if the `input` was +paused before the process was sent to the background. + +The listener function is invoked without passing any arguments. + +```js +rl.on('SIGTSTP', () => { + // This will override SIGTSTP and prevent the program from going to the + // background. + console.log('Caught SIGTSTP.'); +}); +``` + +The `'SIGTSTP'` event is _not_ supported on Windows. + +#### rl.close() + + +The `rl.close()` method closes the `readline.promises.Interface` instance and +relinquishes control over the `input` and `output` streams. When called, +the `'close'` event will be emitted. + +Calling `rl.close()` does not immediately stop other events (including `'line'`) +from being emitted by the `readline.promises.Interface` instance. + +#### rl.pause() + + +The `rl.pause()` method pauses the `input` stream, allowing it to be resumed +later if necessary. + +Calling `rl.pause()` does not immediately pause other events (including +`'line'`) from being emitted by the `readline.promises.Interface` instance. + +#### rl.prompt([preserveCursor]) + + +* `preserveCursor` {boolean} If `true`, prevents the cursor placement from + being reset to `0`. + +The `rl.prompt()` method writes the `readline.promises.Interface` instances +configured `prompt` to a new line in `output` in order to provide a user with a +new location at which to provide input. + +When called, `rl.prompt()` will resume the `input` stream if it has been +paused. + +If the `readline.promises.Interface` was created with `output` set to `null` or +`undefined` the prompt is not written. + +#### rl.question(query) + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* Returns: {Promise} + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then resolves the `Promise` with +the provided input. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readline.promises.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +Example usage: + +```js +(async () => { + const answer = await rl.question('What is your favorite food? '); + + console.log(`Oh, so your favorite food is ${answer}`); +})(); +``` + +#### rl.resume() + + +The `rl.resume()` method resumes the `input` stream if it has been paused. + +#### rl.setPrompt(prompt) + + +* `prompt` {string} + +The `rl.setPrompt()` method sets the prompt that will be written to `output` +whenever `rl.prompt()` is called. + +#### rl.write(data[, key]) + + +* `data` {string} +* `key` {Object} + * `ctrl` {boolean} `true` to indicate the `` key. + * `meta` {boolean} `true` to indicate the `` key. + * `shift` {boolean} `true` to indicate the `` key. + * `name` {string} The name of the a key. + +The `rl.write()` method will write either `data` or a key sequence identified +by `key` to the `output`. The `key` argument is supported only if `output` is +a [TTY][] text terminal. + +If `key` is specified, `data` is ignored. + +When called, `rl.write()` will resume the `input` stream if it has been +paused. + +If the `readline.promises.Interface` was created with `output` set to `null` or +`undefined` the `data` and `key` are not written. + +```js +rl.write('Delete this!'); +// Simulate Ctrl+u to delete the line written previously +rl.write(null, { ctrl: true, name: 'u' }); +``` + +The `rl.write()` method will write the data to the +`readline.promises.Interface`'s `input` *as if it were provided by the user*. + +#### rl\[Symbol.asyncIterator\]() + + +* Returns: {AsyncIterator} + +Create an `AsyncIterator` object that iterates through each line in the input +stream as a string. This method allows asynchronous iteration of +`readline.promises.Interface` objects through `for await...of` loops. + +Errors in the input stream are not forwarded. + +If the loop is terminated with `break`, `throw`, or `return`, +[`rl.close()`][] will be called. In other words, iterating over a +`readline.promises.Interface` will always consume the input stream fully. + +Performance is not on par with the traditional `'line'` event API. Use `'line'` +instead for performance-sensitive applications. + +```js +async function processLineByLine() { + const rl = readline.promises.createInterface({ + // ... + }); + + for await (const line of rl) { + // Each line in the readline input will be successively available here as + // `line`. + } +} +``` + +### readline.promises.clearLine(stream, dir) + + +* `stream` {stream.Writable} +* `dir` {number} + * `-1` - to the left from cursor + * `1` - to the right from cursor + * `0` - the entire line + +The `readline.promises.clearLine()` method clears current line of given [TTY][] +stream in a specified direction identified by `dir`. + +### readline.promises.clearScreenDown(stream) + + +* `stream` {stream.Writable} + +The `readline.promises.clearScreenDown()` method clears the given [TTY][] stream +from the current position of the cursor down. + +### readline.promises.createInterface(options) + + +* `options` {Object} + * `input` {stream.Readable} The [Readable][] stream to listen to. This option + is *required*. + * `output` {stream.Writable} The [Writable][] stream to write readline data + to. + * `completer` {Function} An optional function used for Tab autocompletion. + * `terminal` {boolean} `true` if the `input` and `output` streams should be + treated like a TTY, and have ANSI/VT100 escape codes written to it. + **Default:** checking `isTTY` on the `output` stream upon instantiation. + * `historySize` {number} Maximum number of history lines retained. To disable + the history set this value to `0`. This option makes sense only if + `terminal` is set to `true` by the user or by an internal `output` check, + otherwise the history caching mechanism is not initialized at all. + **Default:** `30`. + * `prompt` {string} The prompt string to use. **Default:** `'> '`. + * `crlfDelay` {number} If the delay between `\r` and `\n` exceeds + `crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate + end-of-line input. `crlfDelay` will be coerced to a number no less than + `100`. It can be set to `Infinity`, in which case `\r` followed by `\n` + will always be considered a single newline (which may be reasonable for + [reading files][] with `\r\n` line delimiter). **Default:** `100`. + * `removeHistoryDuplicates` {boolean} If `true`, when a new input line added + to the history list duplicates an older one, this removes the older line + from the list. **Default:** `false`. + * `escapeCodeTimeout` {number} The duration `readline` will wait for a + character (when reading an ambiguous key sequence in milliseconds one that + can both form a complete key sequence using the input read so far and can + take additional input to complete a longer key sequence). + **Default:** `500`. + +The `readline.promises.createInterface()` method creates a new +`readline.promises.Interface` instance. + +```js +const readline = require('readline').promises; +const rl = readline.promises.createInterface({ + input: process.stdin, + output: process.stdout +}); +``` + +Once the `readline.promises.Interface` instance is created, the most common case +is to listen for the `'line'` event: + +```js +rl.on('line', (line) => { + console.log(`Received: ${line}`); +}); +``` + +If `terminal` is `true` for this instance then the `output` stream will get +the best compatibility if it defines an `output.columns` property and emits +a `'resize'` event on the `output` if or when the columns ever change +([`process.stdout`][] does this automatically when it is a TTY). + +#### Use of the `completer` Function + +The `completer` function takes the current line entered by the user +as an argument, and returns a `Promise` that resolves an `Array` with two +entries: + +* An `Array` with matching entries for the completion. +* The substring that was used for the matching. + +For instance: `[[substr1, substr2, ...], originalsubstring]`. + +```js +function completer(line) { + return new Promise((resolve, reject) => { + const completions = '.help .error .exit .quit .q'.split(' '); + const hits = completions.filter((c) => c.startsWith(line)); + // Show all completions if none found. + resolve([hits.length ? hits : completions, line]); + }); +} +``` + +### readline.promises.cursorTo(stream, x, y) + + +* `stream` {stream.Writable} +* `x` {number} +* `y` {number} + +The `readline.promises.cursorTo()` method moves cursor to the specified position +in a given [TTY][] `stream`. + +### readline.promises.emitKeypressEvents(stream[, interface]) + + +* `stream` {stream.Readable} +* `interface` {readline.promises.Interface} + +The `readline.promises.emitKeypressEvents()` method causes the given +[Readable][] stream to begin emitting `'keypress'` events corresponding to +received input. + +Optionally, `interface` specifies a `readline.promises.Interface` instance for +which autocompletion is disabled when copy-pasted input is detected. + +If the `stream` is a [TTY][], then it must be in raw mode. + +This is automatically called by any `readline.promises` instance on its `input` +if the `input` is a terminal. Closing the `readline.promises` instance does not +stop the `input` from emitting `'keypress'` events. + +```js +readline.promises.emitKeypressEvents(process.stdin); +if (process.stdin.isTTY) + process.stdin.setRawMode(true); +``` + +### readline.promises.moveCursor(stream, dx, dy) + + +* `stream` {stream.Writable} +* `dx` {number} +* `dy` {number} + +The `readline.promises.moveCursor()` method moves the cursor *relative* to its +current position in a given [TTY][] `stream`. + ## Example: Tiny CLI The following example illustrates the use of `readline.Interface` class to diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js new file mode 100644 index 00000000000000..8aa595b3e4df02 --- /dev/null +++ b/lib/internal/readline/interface.js @@ -0,0 +1,1120 @@ +'use strict'; +const { Math, Object } = primordials; +const EventEmitter = require('events'); +const { ERR_INVALID_OPT_VALUE } = require('internal/errors').codes; +const { + clearScreenDown, + cursorTo, + emitKeypressEvents, + getStringWidth, + isFullWidthCodePoint, + kEscapeCodeTimeout, + kUTF16SurrogateThreshold, + moveCursor, + stripVTControlCharacters +} = require('internal/readline/utils'); +const { inspect } = require('internal/util/inspect'); +const { validateString } = require('internal/validators'); + +const kLineObjectStream = Symbol('line object stream'); +const kHistorySize = 30; +const kMincrlfDelay = 100; +// \r\n, \n, or \r followed by something other than \n. +const lineEndingRegEx = /\r?\n|\r(?!\n)/; + +let StringDecoder; // Lazy loaded. +let Readable; // Lazy loaded. + + +function Interface(input, output, completer, terminal) { + if (!(this instanceof Interface)) + return new Interface(input, output, completer, terminal); + + EventEmitter.call(this); + + const opts = normalizeInterfaceArguments(input, output, completer, terminal); + + // Check completer arity, 2 - for async, 1 for sync. + if (typeof opts.completer === 'function') { + const completer = opts.completer; + opts.completer = completer.length === 2 ? + completer : + function completerWrapper(v, cb) { + cb(null, completer(v)); + }; + } + + initializeInterface(this, opts); +} + +Object.setPrototypeOf(Interface.prototype, EventEmitter.prototype); +Object.setPrototypeOf(Interface, EventEmitter); + + +Object.defineProperty(Interface.prototype, 'columns', { + configurable: true, + enumerable: true, + get() { + return this.output && this.output.columns ? this.output.columns : Infinity; + } +}); + + +Interface.prototype.setPrompt = function(prompt) { + this._prompt = prompt; +}; + + +Interface.prototype._setRawMode = function(mode) { + const wasInRawMode = this.input.isRaw; + + if (typeof this.input.setRawMode === 'function') + this.input.setRawMode(mode); + + return wasInRawMode; +}; + + +Interface.prototype.prompt = function(preserveCursor) { + if (this.paused) + this.resume(); + + if (this.terminal && process.env.TERM !== 'dumb') { + if (!preserveCursor) + this.cursor = 0; + this._refreshLine(); + } else { + this._writeToOutput(this._prompt); + } +}; + + +Interface.prototype.question = function(query, cb) { + if (typeof cb === 'function') { + if (this._questionCallback) { + this.prompt(); + } else { + this._oldPrompt = this._prompt; + this.setPrompt(query); + this._questionCallback = cb; + this.prompt(); + } + } +}; + + +Interface.prototype._onLine = function(line) { + if (this._questionCallback) { + const cb = this._questionCallback; + this._questionCallback = null; + this.setPrompt(this._oldPrompt); + cb(line); + } else { + this.emit('line', line); + } +}; + + +Interface.prototype._writeToOutput = function(stringToWrite) { + validateString(stringToWrite, 'stringToWrite'); + + if (this.output !== null && this.output !== undefined) + this.output.write(stringToWrite); +}; + + +Interface.prototype._addHistory = function() { + if (this.line.length === 0) + return ''; + + // If the history is disabled then return the line. + if (this.historySize === 0) + return this.line; + + // If the trimmed line is empty then return the line. + if (this.line.trim().length === 0) + return this.line; + + if (this.history.length === 0 || this.history[0] !== this.line) { + if (this.removeHistoryDuplicates) { + // Remove the older history line if it is identical to new one. + const dupIndex = this.history.indexOf(this.line); + + if (dupIndex !== -1) + this.history.splice(dupIndex, 1); + } + + this.history.unshift(this.line); + + // Only store as many entries as the history size allows. + if (this.history.length > this.historySize) + this.history.pop(); + } + + this.historyIndex = -1; + return this.history[0]; +}; + + +Interface.prototype._refreshLine = function() { + // The line length. + const line = this._prompt + this.line; + const dispPos = this._getDisplayPos(line); + const lineCols = dispPos.cols; + const lineRows = dispPos.rows; + + // The cursor position. + const cursorPos = this._getCursorPos(); + + // First, move to the bottom of the current line, based on cursor position. + const prevRows = this.prevRows || 0; + + if (prevRows > 0) + moveCursor(this.output, 0, -prevRows); + + // Move the cursor to the left edge. + cursorTo(this.output, 0); + + // Erase data. + clearScreenDown(this.output); + + // Write the prompt and the current buffer content. + this._writeToOutput(line); + + // Force the terminal to allocate a new line. + if (lineCols === 0) + this._writeToOutput(' '); + + // Move the cursor to the original position. + cursorTo(this.output, cursorPos.cols); + + const diff = lineRows - cursorPos.rows; + + if (diff > 0) + moveCursor(this.output, 0, -diff); + + this.prevRows = cursorPos.rows; +}; + + +Interface.prototype.close = function() { + if (this.closed) + return; + + this.pause(); + + if (this.terminal) + this._setRawMode(false); + + this.closed = true; + this.emit('close'); +}; + + +Interface.prototype.pause = function() { + if (this.paused) + return; + + this.input.pause(); + this.paused = true; + this.emit('pause'); + return this; +}; + + +Interface.prototype.resume = function() { + if (!this.paused) + return; + + this.input.resume(); + this.paused = false; + this.emit('resume'); + return this; +}; + + +Interface.prototype.write = function(d, key) { + if (this.paused) + this.resume(); + + if (this.terminal) + this._ttyWrite(d, key); + else + this._normalWrite(d); +}; + + +Interface.prototype._normalWrite = function(b) { + if (b === undefined) + return; + + let string = this._decoder.write(b); + + if (this._sawReturnAt && Date.now() - this._sawReturnAt <= this.crlfDelay) { + string = string.replace(/^\n/, ''); + this._sawReturnAt = 0; + } + + // Run test() on the new string chunk, not on the entire line buffer. + const newPartContainsEnding = lineEndingRegEx.test(string); + + if (this._line_buffer) { + string = this._line_buffer + string; + this._line_buffer = null; + } + + if (newPartContainsEnding) { + this._sawReturnAt = string.endsWith('\r') ? Date.now() : 0; + + // Got one or more newlines. Translate them into "line" events. + const lines = string.split(lineEndingRegEx); + // Either '' or (conceivably) the unfinished portion of the next line. + string = lines.pop(); + this._line_buffer = string; + for (let n = 0; n < lines.length; n++) + this._onLine(lines[n]); + } else if (string) { + // No newlines this time. Save the rest for next time. + this._line_buffer = string; + } +}; + + +Interface.prototype._insertString = function(c) { + if (this.cursor < this.line.length) { + const beg = this.line.slice(0, this.cursor); + const end = this.line.slice(this.cursor, this.line.length); + + this.line = beg + c + end; + this.cursor += c.length; + this._refreshLine(); + } else { + this.line += c; + this.cursor += c.length; + + if (this._getCursorPos().cols === 0) + this._refreshLine(); + else + this._writeToOutput(c); + + // A hack to get the line refreshed if it's needed. + this._moveCursor(0); + } +}; + + +Interface.prototype._tabComplete = function(lastKeypressWasTab) { + this.pause(); + this.completer(this.line.slice(0, this.cursor), (err, rv) => { + onTabComplete(err, rv, this, lastKeypressWasTab); + }); +}; + + +Interface.prototype._wordLeft = function() { + if (this.cursor > 0) { + // Reverse the string and match a word near the beginning in order to avoid + // quadratic time complexity. + const leading = this.line.slice(0, this.cursor); + const reversed = leading.split('').reverse().join(''); + const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/); + this._moveCursor(-match[0].length); + } +}; + + +Interface.prototype._wordRight = function() { + if (this.cursor < this.line.length) { + const trailing = this.line.slice(this.cursor); + const match = trailing.match(/^(?:\s+|[^\w\s]+|\w+)\s*/); + this._moveCursor(match[0].length); + } +}; + + +Interface.prototype._deleteLeft = function() { + if (this.cursor > 0 && this.line.length > 0) { + // The number of UTF-16 units comprising the character to the left. + const charSize = charLengthLeft(this.line, this.cursor); + this.line = this.line.slice(0, this.cursor - charSize) + + this.line.slice(this.cursor, this.line.length); + + this.cursor -= charSize; + this._refreshLine(); + } +}; + + +Interface.prototype._deleteRight = function() { + if (this.cursor < this.line.length) { + // The number of UTF-16 units comprising the character to the right. + const charSize = charLengthAt(this.line, this.cursor); + this.line = this.line.slice(0, this.cursor) + + this.line.slice(this.cursor + charSize, this.line.length); + this._refreshLine(); + } +}; + + +Interface.prototype._deleteWordLeft = function() { + if (this.cursor > 0) { + // Reverse the string and match a word near the beginning in order to avoid + // quadratic time complexity. + let leading = this.line.slice(0, this.cursor); + const reversed = leading.split('').reverse().join(''); + const match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/); + leading = leading.slice(0, leading.length - match[0].length); + this.line = leading + this.line.slice(this.cursor, this.line.length); + this.cursor = leading.length; + this._refreshLine(); + } +}; + + +Interface.prototype._deleteWordRight = function() { + if (this.cursor < this.line.length) { + const trailing = this.line.slice(this.cursor); + const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/); + this.line = this.line.slice(0, this.cursor) + + trailing.slice(match[0].length); + this._refreshLine(); + } +}; + + +Interface.prototype._deleteLineLeft = function() { + this.line = this.line.slice(this.cursor); + this.cursor = 0; + this._refreshLine(); +}; + + +Interface.prototype._deleteLineRight = function() { + this.line = this.line.slice(0, this.cursor); + this._refreshLine(); +}; + + +Interface.prototype.clearLine = function() { + this._moveCursor(Infinity); + this._writeToOutput('\r\n'); + this.line = ''; + this.cursor = 0; + this.prevRows = 0; +}; + + +Interface.prototype._line = function() { + const line = this._addHistory(); + this.clearLine(); + this._onLine(line); +}; + + +Interface.prototype._historyNext = function() { + if (this.historyIndex > 0) { + this.historyIndex--; + this.line = this.history[this.historyIndex]; + this.cursor = this.line.length; // Set the cursor to the end of the line. + this._refreshLine(); + } else if (this.historyIndex === 0) { + this.historyIndex = -1; + this.line = ''; + this.cursor = 0; + this._refreshLine(); + } +}; + + +Interface.prototype._historyPrev = function() { + if (this.historyIndex + 1 < this.history.length) { + this.historyIndex++; + this.line = this.history[this.historyIndex]; + this.cursor = this.line.length; // Set cursor to end of line. + + this._refreshLine(); + } +}; + + +// Returns the last character's display position of the given string. +Interface.prototype._getDisplayPos = function(str) { + let offset = 0; + const col = this.columns; + let row = 0; + + str = stripVTControlCharacters(str); + + for (let i = 0, len = str.length; i < len; i++) { + const code = str.codePointAt(i); + + if (code >= 0x10000) // Surrogates. + i++; + + if (code === 0x0a) { // New line \n. + // Row must be incremented by 1 even if offset = 0 or col = +Infinity. + row += Math.ceil(offset / col) || 1; + offset = 0; + continue; + } + + const width = getStringWidth(code); + + if (width === 0 || width === 1) { + offset += width; + } else { // Width equals two. + if ((offset + 1) % col === 0) + offset++; + + offset += 2; + } + } + + const cols = offset % col; + const rows = row + (offset - cols) / col; + return { cols, rows }; +}; + + +// Returns the current cursor's position and line. +Interface.prototype._getCursorPos = function() { + const columns = this.columns; + const strBeforeCursor = this._prompt + this.line.substring(0, this.cursor); + const dispPos = this._getDisplayPos( + stripVTControlCharacters(strBeforeCursor)); + let cols = dispPos.cols; + let rows = dispPos.rows; + // If the cursor is on a full-width character which steps over the line, + // move the cursor to the beginning of the next line. + if (cols + 1 === columns && + this.cursor < this.line.length && + isFullWidthCodePoint(this.line.codePointAt(this.cursor))) { + rows++; + cols = 0; + } + return { cols, rows }; +}; + + +// This function moves cursor dx places to the right +// (-dx for left) and refreshes the line if it is needed. +Interface.prototype._moveCursor = function(dx) { + const oldcursor = this.cursor; + const oldPos = this._getCursorPos(); + this.cursor += dx; + + // Bounds check. + if (this.cursor < 0) + this.cursor = 0; + else if (this.cursor > this.line.length) + this.cursor = this.line.length; + + const newPos = this._getCursorPos(); + + // Check if cursors are in the same row. + if (oldPos.rows === newPos.rows) { + const diffCursor = this.cursor - oldcursor; + let diffWidth; + + if (diffCursor < 0) + diffWidth = -getStringWidth(this.line.substring(this.cursor, oldcursor)); + else if (diffCursor > 0) + diffWidth = getStringWidth(this.line.substring(this.cursor, oldcursor)); + + moveCursor(this.output, diffWidth, 0); + this.prevRows = newPos.rows; + } else { + this._refreshLine(); + } +}; + + +Interface.prototype[Symbol.asyncIterator] = function() { + if (this[kLineObjectStream] === undefined) { + if (Readable === undefined) + Readable = require('stream').Readable; + + const readable = new Readable({ + objectMode: true, + read: () => { + this.resume(); + }, + destroy: (err, cb) => { + this.off('line', lineListener); + this.off('close', closeListener); + this.close(); + cb(err); + } + }); + const lineListener = (input) => { + if (!readable.push(input)) + this.pause(); + }; + const closeListener = () => { + readable.push(null); + }; + this.on('line', lineListener); + this.on('close', closeListener); + this[kLineObjectStream] = readable; + } + + return this[kLineObjectStream][Symbol.asyncIterator](); +}; + + +Interface.prototype._ttyWrite = function(s, key) { + // Handle a write from the tty. + const previousKey = this._previousKey; + key = key || {}; + this._previousKey = key; + + // Ignore escape key, fixes + // https://github.com/nodejs/node-v0.x-archive/issues/2876. + if (key.name === 'escape') + return; + + if (key.ctrl && key.shift) { + // Control key and Shift pressed. + switch (key.name) { + case 'backspace': + this._deleteLineLeft(); + break; + case 'delete': + this._deleteLineRight(); + break; + } + } else if (key.ctrl) { + // Control key pressed. + switch (key.name) { + case 'c': + if (this.listenerCount('SIGINT') > 0) + this.emit('SIGINT'); + else + this.close(); // This readline instance is finished. + break; + case 'h': // Delete left. + this._deleteLeft(); + break; + case 'd': // Delete right or EOF. + if (this.cursor === 0 && this.line.length === 0) + this.close(); // This readline instance is finished. + else if (this.cursor < this.line.length) + this._deleteRight(); + break; + case 'u': // Delete from the current position to the start of the line. + this._deleteLineLeft(); + break; + case 'k': // Delete from the current position to the end of the line. + this._deleteLineRight(); + break; + case 'a': // Go to the start of the line. + this._moveCursor(-Infinity); + break; + case 'e': // Go to the end of the line. + this._moveCursor(Infinity); + break; + case 'b': // Back one character. + this._moveCursor(-charLengthLeft(this.line, this.cursor)); + break; + case 'f': // Forward one character. + this._moveCursor(charLengthAt(this.line, this.cursor)); + break; + case 'l': // Clear the whole screen. + cursorTo(this.output, 0, 0); + clearScreenDown(this.output); + this._refreshLine(); + break; + case 'n': // Next history item. + this._historyNext(); + break; + case 'p': // Previous history item. + this._historyPrev(); + break; + case 'z': + if (process.platform === 'win32') + break; + + if (this.listenerCount('SIGTSTP') > 0) { + this.emit('SIGTSTP'); + } else { + process.once('SIGCONT', (function continueProcess(self) { + return function() { + // Don't raise events if the stream has already been abandoned. + if (!self.paused) { + // Stream must be paused and resumed after SIGCONT to catch + // SIGINT, SIGTSTP, and EOF. + self.pause(); + self.emit('SIGCONT'); + } + // Explicitly re-enable "raw mode" and move the cursor to + // the correct position. + // See https://github.com/joyent/node/issues/3295. + self._setRawMode(true); + self._refreshLine(); + }; + })(this)); + this._setRawMode(false); + process.kill(process.pid, 'SIGTSTP'); + } + break; + case 'w': // Delete backwards to a word boundary. + case 'backspace': + this._deleteWordLeft(); + break; + case 'delete': // Delete forward to a word boundary. + this._deleteWordRight(); + break; + case 'left': + this._wordLeft(); + break; + case 'right': + this._wordRight(); + break; + } + } else if (key.meta) { + // Meta key pressed. + switch (key.name) { + case 'b': // Backward word. + this._wordLeft(); + break; + case 'f': // Forward word. + this._wordRight(); + break; + case 'd': // Delete forward word. + case 'delete': + this._deleteWordRight(); + break; + case 'backspace': // Delete backwards to a word boundary. + this._deleteWordLeft(); + break; + } + } else { + // No modifier keys used. + + // \r bookkeeping is only relevant if a \n comes right after. + if (this._sawReturnAt && key.name !== 'enter') + this._sawReturnAt = 0; + + switch (key.name) { + case 'return': // Carriage return, i.e. \r. + this._sawReturnAt = Date.now(); + this._line(); + break; + case 'enter': + // When key interval > crlfDelay. + if (this._sawReturnAt === 0 || + Date.now() - this._sawReturnAt > this.crlfDelay) { + this._line(); + } + this._sawReturnAt = 0; + break; + case 'backspace': + this._deleteLeft(); + break; + case 'delete': + this._deleteRight(); + break; + case 'left': + // Obtain the code point to the left. + this._moveCursor(-charLengthLeft(this.line, this.cursor)); + break; + case 'right': + this._moveCursor(charLengthAt(this.line, this.cursor)); + break; + case 'home': + this._moveCursor(-Infinity); + break; + case 'end': + this._moveCursor(Infinity); + break; + case 'up': + this._historyPrev(); + break; + case 'down': + this._historyNext(); + break; + case 'tab': + // If tab completion is enabled, perform tab completion. + if (typeof this.completer === 'function' && this.isCompletionEnabled) { + const lastKeypressWasTab = previousKey && previousKey.name === 'tab'; + this._tabComplete(lastKeypressWasTab); + break; + } + // Fall through. + default: + if (typeof s === 'string' && s.length > 0) { + const lines = s.split(/\r\n|\n|\r/); + + for (let i = 0, len = lines.length; i < len; i++) { + if (i > 0) + this._line(); + + this._insertString(lines[i]); + } + } + } + } +}; + + +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + + +function handleGroup(self, group, width, maxColumns) { + if (group.length === 0) + return; + + const minRows = Math.ceil(group.length / maxColumns); + + for (let row = 0; row < minRows; row++) { + for (let col = 0; col < maxColumns; col++) { + const idx = row * maxColumns + col; + + if (idx >= group.length) + break; + + const item = group[idx]; + self._writeToOutput(item); + if (col < maxColumns - 1) { + for (let s = 0; s < width - item.length; s++) + self._writeToOutput(' '); + } + } + + self._writeToOutput('\r\n'); + } + + self._writeToOutput('\r\n'); +} + + +function commonPrefix(strings) { + if (strings.length === 0) + return ''; + + if (strings.length === 1) + return strings[0]; + + const sorted = strings.slice().sort(); + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + + for (let i = 0, len = min.length; i < len; i++) { + if (min[i] !== max[i]) + return min.slice(0, i); + } + + return min; +} + + +function ttyWriteDumb(s, key) { + key = key || {}; + + if (key.name === 'escape') + return; + + if (this._sawReturnAt && key.name !== 'enter') + this._sawReturnAt = 0; + + if (key.ctrl && key.name === 'c') { + if (this.listenerCount('SIGINT') > 0) + this.emit('SIGINT'); + else + this.close(); // The readline instance is finished. + } + + switch (key.name) { + case 'return': // Carriage return, i.e. \r. + this._sawReturnAt = Date.now(); + this._line(); + break; + case 'enter': + // When key interval > crlfDelay. + if (this._sawReturnAt === 0 || + Date.now() - this._sawReturnAt > this.crlfDelay) { + this._line(); + } + this._sawReturnAt = 0; + break; + default: + if (typeof s === 'string' && s.length !== 0) { + this.line += s; + this.cursor += s.length; + this._writeToOutput(s); + } + } +} + + +function charLengthLeft(str, i) { + if (i <= 0) + return 0; + + if (i > 1 && str.codePointAt(i - 2) >= kUTF16SurrogateThreshold || + str.codePointAt(i - 1) >= kUTF16SurrogateThreshold) { + return 2; + } + + return 1; +} + + +function charLengthAt(str, i) { + if (str.length <= i) + return 0; + return str.codePointAt(i) >= kUTF16SurrogateThreshold ? 2 : 1; +} + + +function onTabComplete(err, completionResult, rlInterface, lastKeypressWasTab) { + rlInterface.resume(); + + if (err) { + rlInterface._writeToOutput(`tab completion error ${inspect(err)}`); + return; + } + + const completions = completionResult[0]; + const completeOn = completionResult[1]; // The text that was completed. + + if (completions && completions.length) { + // Apply/show completions. + if (lastKeypressWasTab) { + rlInterface._writeToOutput('\r\n'); + const width = completions.reduce(function completionReducer(a, b) { + return a.length > b.length ? a : b; + }).length + 2; // Two space padding. + let maxColumns = Math.floor(rlInterface.columns / width); + + if (!maxColumns || maxColumns === Infinity) + maxColumns = 1; + + let group = []; + + for (let i = 0; i < completions.length; i++) { + const c = completions[i]; + + if (c === '') { + handleGroup(rlInterface, group, width, maxColumns); + group = []; + } else { + group.push(c); + } + } + + handleGroup(rlInterface, group, width, maxColumns); + } + + // If there is a common prefix to all matches, then apply that portion. + const f = completions.filter((e) => e); + const prefix = commonPrefix(f); + + if (prefix.length > completeOn.length) + rlInterface._insertString(prefix.slice(completeOn.length)); + + rlInterface._refreshLine(); + } +} + + +function onData(data) { + this._normalWrite(data); +} + + +function onEnd() { + if (typeof this._line_buffer === 'string' && this._line_buffer.length > 0) + this.emit('line', this._line_buffer); + + this.close(); +} + + +function onTermEnd() { + if (typeof this.line === 'string' && this.line.length > 0) + this.emit('line', this.line); + + this.close(); +} + + +function onKeyPress(s, key) { + this._ttyWrite(s, key); + + if (key && key.sequence) { + // If the key.sequence is half of a surrogate pair + // (>= 0xd800 and <= 0xdfff), refresh the line so + // the character is displayed appropriately. + const ch = key.sequence.codePointAt(0); + + if (ch >= 0xd800 && ch <= 0xdfff) + this._refreshLine(); + } +} + + +function onResize() { + this._refreshLine(); +} + + +function normalizeInterfaceArguments(input, output, completer, terminal) { + let historySize; + let prompt; + let crlfDelay; + let removeHistoryDuplicates; + let escapeCodeTimeout; + + if (input && input.input) { + // An options object was given. + output = input.output; + completer = input.completer; + terminal = input.terminal; + historySize = input.historySize; + prompt = input.prompt; + crlfDelay = input.crlfDelay; + removeHistoryDuplicates = input.removeHistoryDuplicates; + escapeCodeTimeout = input.escapeCodeTimeout; + input = input.input; + } + + // Validate completer. + if (completer !== undefined && typeof completer !== 'function') + throw new ERR_INVALID_OPT_VALUE('completer', completer); + + // Validate terminal. For backwards compatibility, check the isTTY prop of the + // output stream when `terminal` was not specified. + if (terminal === undefined && output != null) + terminal = !!output.isTTY; + else + terminal = !!terminal; + + // Validate historySize. + if (historySize === undefined) { + historySize = kHistorySize; + } else if (typeof historySize !== 'number' || + Number.isNaN(historySize) || + historySize < 0) { + throw new ERR_INVALID_OPT_VALUE.RangeError('historySize', historySize); + } + + // Validate prompt. + if (prompt === undefined) + prompt = '> '; + + // Validate crlfDelay. + crlfDelay = crlfDelay ? Math.max(kMincrlfDelay, crlfDelay) : kMincrlfDelay; + + // Validate removeHistoryDuplicates. + removeHistoryDuplicates = !!removeHistoryDuplicates; + + // Validate escapeCodeTimeout. + if (escapeCodeTimeout === undefined) + escapeCodeTimeout = kEscapeCodeTimeout; + else if (!Number.isFinite(escapeCodeTimeout)) + throw new ERR_INVALID_OPT_VALUE('escapeCodeTimeout', escapeCodeTimeout); + + return { + input, + output, + completer, + terminal, + historySize, + prompt, + crlfDelay, + removeHistoryDuplicates, + escapeCodeTimeout + }; +} + + +function initializeInterface(iface, options) { + iface.input = options.input; + iface.output = options.output; + iface.completer = options.completer; + iface.terminal = options.terminal; + iface.historySize = options.historySize; + iface.setPrompt(options.prompt); + iface.crlfDelay = options.crlfDelay; + iface.removeHistoryDuplicates = options.removeHistoryDuplicates; + iface.escapeCodeTimeout = options.escapeCodeTimeout; + iface.isCompletionEnabled = true; + iface._sawReturnAt = 0; + iface._sawKeyPress = false; + iface._previousKey = null; + iface[kLineObjectStream] = undefined; + + if (process.env.TERM === 'dumb') + iface._ttyWrite = ttyWriteDumb.bind(iface); + + const input = iface.input; + + if (!iface.terminal) { + const ondata = onData.bind(iface); + const onend = onEnd.bind(iface); + + function onInterfaceCloseWithoutTerminal() { + input.removeListener('data', ondata); + input.removeListener('end', onend); + } + + input.on('data', ondata); + input.on('end', onend); + iface.once('close', onInterfaceCloseWithoutTerminal); + + if (StringDecoder === undefined) + StringDecoder = require('string_decoder').StringDecoder; + + iface._decoder = new StringDecoder('utf8'); + } else { + const onkeypress = onKeyPress.bind(iface); + const ontermend = onTermEnd.bind(iface); + const output = iface.output; + const onresize = output == null ? null : onResize.bind(iface); + + function onInterfaceCloseWithTerminal() { + input.removeListener('keypress', onkeypress); + input.removeListener('end', ontermend); + + if (onresize !== null) + output.removeListener('resize', onresize); + } + + emitKeypressEvents(input, iface); + + // `input` usually refers to stdin. + input.on('keypress', onkeypress); + input.on('end', ontermend); + + // The current line. + iface.line = ''; + + iface._setRawMode(true); + iface.terminal = true; + + // The cursor position on the line. + iface.cursor = 0; + + iface.history = []; + iface.historyIndex = -1; + + if (onresize !== null) + output.on('resize', onresize); + + iface.once('close', onInterfaceCloseWithTerminal); + } + + input.resume(); +} + + +module.exports = { + createInterface, + initializeInterface, + onTabComplete, + normalizeInterfaceArguments, + Interface +}; diff --git a/lib/internal/readline/promises.js b/lib/internal/readline/promises.js new file mode 100644 index 00000000000000..96041a694ff2ef --- /dev/null +++ b/lib/internal/readline/promises.js @@ -0,0 +1,72 @@ +'use strict'; +const { Object } = primordials; +const EventEmitter = require('events'); +const { + Interface: CallbackInterface, + initializeInterface, + onTabComplete, + normalizeInterfaceArguments +} = require('internal/readline/interface'); + + +class Interface extends EventEmitter { + constructor(input, output, completer, terminal) { + super(); + const opts = normalizeInterfaceArguments( + input, output, completer, terminal); + initializeInterface(this, opts); + } + + question(query) { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + + if (!this._questionCallback) { + this._oldPrompt = this._prompt; + this.setPrompt(query); + this._questionCallback = resolve; + } + + this.prompt(); + return promise; + } + + async _tabComplete(lastKeypressWasTab) { + this.pause(); + + try { + const line = this.line.slice(0, this.cursor); + const results = await this.completer(line); + onTabComplete(null, results, this, lastKeypressWasTab); + } catch (err) { + onTabComplete(err, null, this, lastKeypressWasTab); + } + } +} + +// Copy the rest of the callback interface over to this interface. +Object.keys(CallbackInterface.prototype).forEach((keyName) => { + if (Interface.prototype[keyName] === undefined) + Interface.prototype[keyName] = CallbackInterface.prototype[keyName]; +}); + +Object.defineProperty(Interface.prototype, 'columns', { + configurable: true, + enumerable: true, + get() { + return this.output && this.output.columns ? this.output.columns : Infinity; + } +}); + +Interface.prototype[Symbol.asyncIterator] = + CallbackInterface.prototype[Symbol.asyncIterator]; + + +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + + +module.exports = { createInterface, Interface }; diff --git a/lib/internal/readline.js b/lib/internal/readline/utils.js similarity index 75% rename from lib/internal/readline.js rename to lib/internal/readline/utils.js index a5611942977df8..a46fdb212c175e 100644 --- a/lib/internal/readline.js +++ b/lib/internal/readline/utils.js @@ -1,22 +1,32 @@ 'use strict'; -// Regex used for ansi escape code splitting +const { ERR_INVALID_CURSOR_POS } = require('internal/errors').codes; +const { clearTimeout, setTimeout } = require('timers'); + +// Regex used for ansi escape code splitting. // Adopted from https://github.com/chalk/ansi-regex/blob/master/index.js // License: MIT, authors: @sindresorhus, Qix-, and arjunmehta -// Matches all ansi escape code sequences in a string +// Matches all ansi escape code sequences in a string. /* eslint-disable no-control-regex */ const ansi = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; /* eslint-enable no-control-regex */ +// GNU readline library - keyseq-timeout is 500ms (default) +const kEscapeCodeTimeout = 500; + +const kKeypressDecoder = Symbol('keypress-decoder'); +const kEscapeDecoder = Symbol('escape-decoder'); +const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 const kEscape = '\x1b'; +let StringDecoder; // Lazy loaded. let getStringWidth; let isFullWidthCodePoint; function CSI(strings, ...args) { let ret = `${kEscape}[`; - for (var n = 0; n < strings.length; n++) { + for (let n = 0; n < strings.length; n++) { ret += strings[n]; if (n < args.length) ret += args[n]; @@ -60,10 +70,10 @@ if (internalBinding('config').hasIntl) { str = stripVTControlCharacters(String(str)); - for (var i = 0; i < str.length; i++) { + for (let i = 0; i < str.length; i++) { const code = str.codePointAt(i); - if (code >= 0x10000) { // surrogates + if (code >= kUTF16SurrogateThreshold) { // Surrogates. i++; } @@ -430,10 +440,149 @@ function* emitKeys(stream) { } } + +function moveCursor(stream, dx, dy) { + // Moves the cursor relative to its current location. + if (stream == null) + return; + + if (dx < 0) + stream.write(CSI`${-dx}D`); + else if (dx > 0) + stream.write(CSI`${dx}C`); + + if (dy < 0) + stream.write(CSI`${-dy}A`); + else if (dy > 0) + stream.write(CSI`${dy}B`); +} + + +function clearScreenDown(stream) { + // Clears the screen from the current position of the cursor down. + if (stream == null) + return; + + stream.write(CSI.kClearScreenDown); +} + + +function clearLine(stream, dir) { + // Clears the current line the cursor is on: + // -1 for left of the cursor. + // +1 for right of the cursor. + // 0 for the entire line. + if (stream == null) + return; + + if (dir < 0) + stream.write(CSI.kClearToBeginning); // Clear to the beginning of the line. + else if (dir > 0) + stream.write(CSI.kClearToEnd); // Clear to the end of the line. + else + stream.write(CSI.kClearLine); // Clear the entire line. +} + + +function cursorTo(stream, x, y) { + // Moves the cursor to the x and y coordinate on the given stream. + if (stream == null) + return; + + if (typeof x !== 'number' && typeof y !== 'number') + return; + + if (typeof x !== 'number') + throw new ERR_INVALID_CURSOR_POS(); + + if (typeof y !== 'number') + stream.write(CSI`${x + 1}G`); + else + stream.write(CSI`${y + 1};${x + 1}H`); +} + + +function emitKeypressEvents(stream, iface) { + // Accepts a readable stream instance and makes it emit "keypress" events. + if (stream[kKeypressDecoder]) + return; + + if (StringDecoder === undefined) + StringDecoder = require('string_decoder').StringDecoder; + + stream[kKeypressDecoder] = new StringDecoder('utf8'); + stream[kEscapeDecoder] = emitKeys(stream); + stream[kEscapeDecoder].next(); + + const escapeCodeTimeout = () => stream[kEscapeDecoder].next(''); + let timeoutId; + + function onData(b) { + if (stream.listenerCount('keypress') > 0) { + const r = stream[kKeypressDecoder].write(b); + + if (r) { + clearTimeout(timeoutId); + + if (iface) + iface._sawKeyPress = r.length === 1; + + for (let i = 0; i < r.length; i++) { + if (r[i] === '\t' && typeof r[i + 1] === 'string' && iface) + iface.isCompletionEnabled = false; + + try { + stream[kEscapeDecoder].next(r[i]); + // Escape letter at the tail position. + if (r[i] === kEscape && i + 1 === r.length) { + timeoutId = setTimeout( + escapeCodeTimeout, + iface ? iface.escapeCodeTimeout : kEscapeCodeTimeout + ); + } + } catch (err) { + // If the generator throws (it could happen in the `keypress` + // event), restart it. + stream[kEscapeDecoder] = emitKeys(stream); + stream[kEscapeDecoder].next(); + throw err; + } finally { + if (iface) + iface.isCompletionEnabled = true; + } + } + } + } else { + // Nobody is watching anyway. + stream.removeListener('data', onData); + stream.on('newListener', onNewListener); + } + } + + function onNewListener(event) { + if (event === 'keypress') { + stream.on('data', onData); + stream.removeListener('newListener', onNewListener); + } + } + + if (stream.listenerCount('keypress') > 0) + stream.on('data', onData); + else + stream.on('newListener', onNewListener); +} + + module.exports = { - emitKeys, + clearLine, + clearScreenDown, + cursorTo, + emitKeypressEvents, getStringWidth, isFullWidthCodePoint, + kEscapeCodeTimeout, + kUTF16SurrogateThreshold, + moveCursor, stripVTControlCharacters, - CSI + CSI // CSI is only exported for testing purposes. }; diff --git a/lib/readline.js b/lib/readline.js index b9e61beed0807b..f31b2daa963a50 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -27,1239 +27,15 @@ 'use strict'; -const { Math, Object } = primordials; - -const { - ERR_INVALID_CURSOR_POS, - ERR_INVALID_OPT_VALUE -} = require('internal/errors').codes; -const { validateString } = require('internal/validators'); -const { inspect } = require('internal/util/inspect'); -const EventEmitter = require('events'); -const { - CSI, - emitKeys, - getStringWidth, - isFullWidthCodePoint, - stripVTControlCharacters -} = require('internal/readline'); - -const { clearTimeout, setTimeout } = require('timers'); +const { Object } = primordials; +const { createInterface, Interface } = require('internal/readline/interface'); const { - kEscape, - kClearToBeginning, - kClearToEnd, - kClearLine, - kClearScreenDown -} = CSI; - -// Lazy load StringDecoder for startup performance. -let StringDecoder; - -// Lazy load Readable for startup performance. -let Readable; - -const kHistorySize = 30; -const kMincrlfDelay = 100; -// \r\n, \n, or \r followed by something other than \n -const lineEnding = /\r?\n|\r(?!\n)/; - -const kLineObjectStream = Symbol('line object stream'); - -const KEYPRESS_DECODER = Symbol('keypress-decoder'); -const ESCAPE_DECODER = Symbol('escape-decoder'); - -// GNU readline library - keyseq-timeout is 500ms (default) -const ESCAPE_CODE_TIMEOUT = 500; - -function createInterface(input, output, completer, terminal) { - return new Interface(input, output, completer, terminal); -} - - -function Interface(input, output, completer, terminal) { - if (!(this instanceof Interface)) { - return new Interface(input, output, completer, terminal); - } - - if (StringDecoder === undefined) - StringDecoder = require('string_decoder').StringDecoder; - - this._sawReturnAt = 0; - this.isCompletionEnabled = true; - this._sawKeyPress = false; - this._previousKey = null; - this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT; - - EventEmitter.call(this); - var historySize; - var removeHistoryDuplicates = false; - let crlfDelay; - let prompt = '> '; - - if (input && input.input) { - // An options object was given - output = input.output; - completer = input.completer; - terminal = input.terminal; - historySize = input.historySize; - removeHistoryDuplicates = input.removeHistoryDuplicates; - if (input.prompt !== undefined) { - prompt = input.prompt; - } - if (input.escapeCodeTimeout !== undefined) { - if (Number.isFinite(input.escapeCodeTimeout)) { - this.escapeCodeTimeout = input.escapeCodeTimeout; - } else { - throw new ERR_INVALID_OPT_VALUE( - 'escapeCodeTimeout', - this.escapeCodeTimeout - ); - } - } - crlfDelay = input.crlfDelay; - input = input.input; - } - - if (completer !== undefined && typeof completer !== 'function') { - throw new ERR_INVALID_OPT_VALUE('completer', completer); - } - - if (historySize === undefined) { - historySize = kHistorySize; - } - - if (typeof historySize !== 'number' || - Number.isNaN(historySize) || - historySize < 0) { - throw new ERR_INVALID_OPT_VALUE.RangeError('historySize', historySize); - } - - // Backwards compat; check the isTTY prop of the output stream - // when `terminal` was not specified - if (terminal === undefined && !(output === null || output === undefined)) { - terminal = !!output.isTTY; - } - - const self = this; - - this.output = output; - this.input = input; - this.historySize = historySize; - this.removeHistoryDuplicates = !!removeHistoryDuplicates; - this.crlfDelay = crlfDelay ? - Math.max(kMincrlfDelay, crlfDelay) : kMincrlfDelay; - // Check arity, 2 - for async, 1 for sync - if (typeof completer === 'function') { - this.completer = completer.length === 2 ? - completer : - function completerWrapper(v, cb) { - cb(null, completer(v)); - }; - } - - this.setPrompt(prompt); - - this.terminal = !!terminal; - - if (process.env.TERM === 'dumb') { - this._ttyWrite = _ttyWriteDumb.bind(this); - } - - function ondata(data) { - self._normalWrite(data); - } - - function onend() { - if (typeof self._line_buffer === 'string' && - self._line_buffer.length > 0) { - self.emit('line', self._line_buffer); - } - self.close(); - } - - function ontermend() { - if (typeof self.line === 'string' && self.line.length > 0) { - self.emit('line', self.line); - } - self.close(); - } - - function onkeypress(s, key) { - self._ttyWrite(s, key); - if (key && key.sequence) { - // If the key.sequence is half of a surrogate pair - // (>= 0xd800 and <= 0xdfff), refresh the line so - // the character is displayed appropriately. - const ch = key.sequence.codePointAt(0); - if (ch >= 0xd800 && ch <= 0xdfff) - self._refreshLine(); - } - } - - function onresize() { - self._refreshLine(); - } - - this[kLineObjectStream] = undefined; - - if (!this.terminal) { - function onSelfCloseWithoutTerminal() { - input.removeListener('data', ondata); - input.removeListener('end', onend); - } - - input.on('data', ondata); - input.on('end', onend); - self.once('close', onSelfCloseWithoutTerminal); - this._decoder = new StringDecoder('utf8'); - } else { - function onSelfCloseWithTerminal() { - input.removeListener('keypress', onkeypress); - input.removeListener('end', ontermend); - if (output !== null && output !== undefined) { - output.removeListener('resize', onresize); - } - } - - emitKeypressEvents(input, this); - - // `input` usually refers to stdin - input.on('keypress', onkeypress); - input.on('end', ontermend); - - // Current line - this.line = ''; - - this._setRawMode(true); - this.terminal = true; - - // Cursor position on the line. - this.cursor = 0; - - this.history = []; - this.historyIndex = -1; - - if (output !== null && output !== undefined) - output.on('resize', onresize); - - self.once('close', onSelfCloseWithTerminal); - } - - input.resume(); -} - -Object.setPrototypeOf(Interface.prototype, EventEmitter.prototype); -Object.setPrototypeOf(Interface, EventEmitter); - -Object.defineProperty(Interface.prototype, 'columns', { - configurable: true, - enumerable: true, - get: function() { - var columns = Infinity; - if (this.output && this.output.columns) - columns = this.output.columns; - return columns; - } -}); - -Interface.prototype.setPrompt = function(prompt) { - this._prompt = prompt; -}; - - -Interface.prototype._setRawMode = function(mode) { - const wasInRawMode = this.input.isRaw; - - if (typeof this.input.setRawMode === 'function') { - this.input.setRawMode(mode); - } - - return wasInRawMode; -}; - - -Interface.prototype.prompt = function(preserveCursor) { - if (this.paused) this.resume(); - if (this.terminal && process.env.TERM !== 'dumb') { - if (!preserveCursor) this.cursor = 0; - this._refreshLine(); - } else { - this._writeToOutput(this._prompt); - } -}; - - -Interface.prototype.question = function(query, cb) { - if (typeof cb === 'function') { - if (this._questionCallback) { - this.prompt(); - } else { - this._oldPrompt = this._prompt; - this.setPrompt(query); - this._questionCallback = cb; - this.prompt(); - } - } -}; - - -Interface.prototype._onLine = function(line) { - if (this._questionCallback) { - var cb = this._questionCallback; - this._questionCallback = null; - this.setPrompt(this._oldPrompt); - cb(line); - } else { - this.emit('line', line); - } -}; - -Interface.prototype._writeToOutput = function _writeToOutput(stringToWrite) { - validateString(stringToWrite, 'stringToWrite'); - - if (this.output !== null && this.output !== undefined) { - this.output.write(stringToWrite); - } -}; - -Interface.prototype._addHistory = function() { - if (this.line.length === 0) return ''; - - // If the history is disabled then return the line - if (this.historySize === 0) return this.line; - - // If the trimmed line is empty then return the line - if (this.line.trim().length === 0) return this.line; - - if (this.history.length === 0 || this.history[0] !== this.line) { - if (this.removeHistoryDuplicates) { - // Remove older history line if identical to new one - const dupIndex = this.history.indexOf(this.line); - if (dupIndex !== -1) this.history.splice(dupIndex, 1); - } - - this.history.unshift(this.line); - - // Only store so many - if (this.history.length > this.historySize) this.history.pop(); - } - - this.historyIndex = -1; - return this.history[0]; -}; - - -Interface.prototype._refreshLine = function() { - // line length - const line = this._prompt + this.line; - const dispPos = this._getDisplayPos(line); - const lineCols = dispPos.cols; - const lineRows = dispPos.rows; - - // cursor position - const cursorPos = this._getCursorPos(); - - // First move to the bottom of the current line, based on cursor pos - const prevRows = this.prevRows || 0; - if (prevRows > 0) { - moveCursor(this.output, 0, -prevRows); - } - - // Cursor to left edge. - cursorTo(this.output, 0); - // erase data - clearScreenDown(this.output); - - // Write the prompt and the current buffer content. - this._writeToOutput(line); - - // Force terminal to allocate a new line - if (lineCols === 0) { - this._writeToOutput(' '); - } - - // Move cursor to original position. - cursorTo(this.output, cursorPos.cols); - - const diff = lineRows - cursorPos.rows; - if (diff > 0) { - moveCursor(this.output, 0, -diff); - } - - this.prevRows = cursorPos.rows; -}; - - -Interface.prototype.close = function() { - if (this.closed) return; - this.pause(); - if (this.terminal) { - this._setRawMode(false); - } - this.closed = true; - this.emit('close'); -}; - - -Interface.prototype.pause = function() { - if (this.paused) return; - this.input.pause(); - this.paused = true; - this.emit('pause'); - return this; -}; - - -Interface.prototype.resume = function() { - if (!this.paused) return; - this.input.resume(); - this.paused = false; - this.emit('resume'); - return this; -}; - - -Interface.prototype.write = function(d, key) { - if (this.paused) this.resume(); - if (this.terminal) { - this._ttyWrite(d, key); - } else { - this._normalWrite(d); - } -}; - -Interface.prototype._normalWrite = function(b) { - if (b === undefined) { - return; - } - var string = this._decoder.write(b); - if (this._sawReturnAt && - Date.now() - this._sawReturnAt <= this.crlfDelay) { - string = string.replace(/^\n/, ''); - this._sawReturnAt = 0; - } - - // Run test() on the new string chunk, not on the entire line buffer. - const newPartContainsEnding = lineEnding.test(string); - - if (this._line_buffer) { - string = this._line_buffer + string; - this._line_buffer = null; - } - if (newPartContainsEnding) { - this._sawReturnAt = string.endsWith('\r') ? Date.now() : 0; - - // Got one or more newlines; process into "line" events - var lines = string.split(lineEnding); - // Either '' or (conceivably) the unfinished portion of the next line - string = lines.pop(); - this._line_buffer = string; - for (var n = 0; n < lines.length; n++) - this._onLine(lines[n]); - } else if (string) { - // No newlines this time, save what we have for next time - this._line_buffer = string; - } -}; - -Interface.prototype._insertString = function(c) { - if (this.cursor < this.line.length) { - var beg = this.line.slice(0, this.cursor); - var end = this.line.slice(this.cursor, this.line.length); - this.line = beg + c + end; - this.cursor += c.length; - this._refreshLine(); - } else { - this.line += c; - this.cursor += c.length; - - if (this._getCursorPos().cols === 0) { - this._refreshLine(); - } else { - this._writeToOutput(c); - } - - // A hack to get the line refreshed if it's needed - this._moveCursor(0); - } -}; - -Interface.prototype._tabComplete = function(lastKeypressWasTab) { - const self = this; - - self.pause(); - self.completer(self.line.slice(0, self.cursor), function onComplete(err, rv) { - self.resume(); - - if (err) { - self._writeToOutput(`tab completion error ${inspect(err)}`); - return; - } - - const completions = rv[0]; - const completeOn = rv[1]; // The text that was completed - if (completions && completions.length) { - // Apply/show completions. - if (lastKeypressWasTab) { - self._writeToOutput('\r\n'); - var width = completions.reduce(function completionReducer(a, b) { - return a.length > b.length ? a : b; - }).length + 2; // 2 space padding - var maxColumns = Math.floor(self.columns / width); - if (!maxColumns || maxColumns === Infinity) { - maxColumns = 1; - } - var group = []; - for (var i = 0; i < completions.length; i++) { - var c = completions[i]; - if (c === '') { - handleGroup(self, group, width, maxColumns); - group = []; - } else { - group.push(c); - } - } - handleGroup(self, group, width, maxColumns); - } - - // If there is a common prefix to all matches, then apply that portion. - var f = completions.filter((e) => e); - var prefix = commonPrefix(f); - if (prefix.length > completeOn.length) { - self._insertString(prefix.slice(completeOn.length)); - } - - self._refreshLine(); - } - }); -}; - -// this = Interface instance -function handleGroup(self, group, width, maxColumns) { - if (group.length === 0) { - return; - } - const minRows = Math.ceil(group.length / maxColumns); - for (var row = 0; row < minRows; row++) { - for (var col = 0; col < maxColumns; col++) { - var idx = row * maxColumns + col; - if (idx >= group.length) { - break; - } - var item = group[idx]; - self._writeToOutput(item); - if (col < maxColumns - 1) { - for (var s = 0; s < width - item.length; s++) { - self._writeToOutput(' '); - } - } - } - self._writeToOutput('\r\n'); - } - self._writeToOutput('\r\n'); -} - -function commonPrefix(strings) { - if (!strings || strings.length === 0) { - return ''; - } - if (strings.length === 1) return strings[0]; - const sorted = strings.slice().sort(); - const min = sorted[0]; - const max = sorted[sorted.length - 1]; - for (var i = 0, len = min.length; i < len; i++) { - if (min[i] !== max[i]) { - return min.slice(0, i); - } - } - return min; -} - - -Interface.prototype._wordLeft = function() { - if (this.cursor > 0) { - // Reverse the string and match a word near beginning - // to avoid quadratic time complexity - var leading = this.line.slice(0, this.cursor); - var reversed = leading.split('').reverse().join(''); - var match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/); - this._moveCursor(-match[0].length); - } -}; - - -Interface.prototype._wordRight = function() { - if (this.cursor < this.line.length) { - var trailing = this.line.slice(this.cursor); - var match = trailing.match(/^(?:\s+|[^\w\s]+|\w+)\s*/); - this._moveCursor(match[0].length); - } -}; - -function charLengthLeft(str, i) { - if (i <= 0) - return 0; - if (i > 1 && str.codePointAt(i - 2) >= 2 ** 16 || - str.codePointAt(i - 1) >= 2 ** 16) { - return 2; - } - return 1; -} - -function charLengthAt(str, i) { - if (str.length <= i) - return 0; - return str.codePointAt(i) >= 2 ** 16 ? 2 : 1; -} - -Interface.prototype._deleteLeft = function() { - if (this.cursor > 0 && this.line.length > 0) { - // The number of UTF-16 units comprising the character to the left - const charSize = charLengthLeft(this.line, this.cursor); - this.line = this.line.slice(0, this.cursor - charSize) + - this.line.slice(this.cursor, this.line.length); - - this.cursor -= charSize; - this._refreshLine(); - } -}; - - -Interface.prototype._deleteRight = function() { - if (this.cursor < this.line.length) { - // The number of UTF-16 units comprising the character to the left - const charSize = charLengthAt(this.line, this.cursor); - this.line = this.line.slice(0, this.cursor) + - this.line.slice(this.cursor + charSize, this.line.length); - this._refreshLine(); - } -}; - - -Interface.prototype._deleteWordLeft = function() { - if (this.cursor > 0) { - // Reverse the string and match a word near beginning - // to avoid quadratic time complexity - var leading = this.line.slice(0, this.cursor); - var reversed = leading.split('').reverse().join(''); - var match = reversed.match(/^\s*(?:[^\w\s]+|\w+)?/); - leading = leading.slice(0, leading.length - match[0].length); - this.line = leading + this.line.slice(this.cursor, this.line.length); - this.cursor = leading.length; - this._refreshLine(); - } -}; - - -Interface.prototype._deleteWordRight = function() { - if (this.cursor < this.line.length) { - var trailing = this.line.slice(this.cursor); - var match = trailing.match(/^(?:\s+|\W+|\w+)\s*/); - this.line = this.line.slice(0, this.cursor) + - trailing.slice(match[0].length); - this._refreshLine(); - } -}; - - -Interface.prototype._deleteLineLeft = function() { - this.line = this.line.slice(this.cursor); - this.cursor = 0; - this._refreshLine(); -}; - - -Interface.prototype._deleteLineRight = function() { - this.line = this.line.slice(0, this.cursor); - this._refreshLine(); -}; - - -Interface.prototype.clearLine = function() { - this._moveCursor(+Infinity); - this._writeToOutput('\r\n'); - this.line = ''; - this.cursor = 0; - this.prevRows = 0; -}; - - -Interface.prototype._line = function() { - const line = this._addHistory(); - this.clearLine(); - this._onLine(line); -}; - - -Interface.prototype._historyNext = function() { - if (this.historyIndex > 0) { - this.historyIndex--; - this.line = this.history[this.historyIndex]; - this.cursor = this.line.length; // Set cursor to end of line. - this._refreshLine(); - - } else if (this.historyIndex === 0) { - this.historyIndex = -1; - this.cursor = 0; - this.line = ''; - this._refreshLine(); - } -}; - - -Interface.prototype._historyPrev = function() { - if (this.historyIndex + 1 < this.history.length) { - this.historyIndex++; - this.line = this.history[this.historyIndex]; - this.cursor = this.line.length; // Set cursor to end of line. - - this._refreshLine(); - } -}; - - -// Returns the last character's display position of the given string -Interface.prototype._getDisplayPos = function(str) { - var offset = 0; - const col = this.columns; - var row = 0; - var code; - str = stripVTControlCharacters(str); - for (var i = 0, len = str.length; i < len; i++) { - code = str.codePointAt(i); - if (code >= 0x10000) { // surrogates - i++; - } - if (code === 0x0a) { // new line \n - // row must be incremented by 1 even if offset = 0 or col = +Infinity - row += Math.ceil(offset / col) || 1; - offset = 0; - continue; - } - const width = getStringWidth(code); - if (width === 0 || width === 1) { - offset += width; - } else { // width === 2 - if ((offset + 1) % col === 0) { - offset++; - } - offset += 2; - } - } - const cols = offset % col; - const rows = row + (offset - cols) / col; - return { cols: cols, rows: rows }; -}; - - -// Returns current cursor's position and line -Interface.prototype._getCursorPos = function() { - const columns = this.columns; - const strBeforeCursor = this._prompt + this.line.substring(0, this.cursor); - const dispPos = this._getDisplayPos( - stripVTControlCharacters(strBeforeCursor)); - var cols = dispPos.cols; - var rows = dispPos.rows; - // If the cursor is on a full-width character which steps over the line, - // move the cursor to the beginning of the next line. - if (cols + 1 === columns && - this.cursor < this.line.length && - isFullWidthCodePoint(this.line.codePointAt(this.cursor))) { - rows++; - cols = 0; - } - return { cols: cols, rows: rows }; -}; - - -// This function moves cursor dx places to the right -// (-dx for left) and refreshes the line if it is needed -Interface.prototype._moveCursor = function(dx) { - const oldcursor = this.cursor; - const oldPos = this._getCursorPos(); - this.cursor += dx; - - // bounds check - if (this.cursor < 0) this.cursor = 0; - else if (this.cursor > this.line.length) this.cursor = this.line.length; - - const newPos = this._getCursorPos(); - - // Check if cursors are in the same line - if (oldPos.rows === newPos.rows) { - var diffCursor = this.cursor - oldcursor; - var diffWidth; - if (diffCursor < 0) { - diffWidth = -getStringWidth( - this.line.substring(this.cursor, oldcursor) - ); - } else if (diffCursor > 0) { - diffWidth = getStringWidth( - this.line.substring(this.cursor, oldcursor) - ); - } - moveCursor(this.output, diffWidth, 0); - this.prevRows = newPos.rows; - } else { - this._refreshLine(); - } -}; - -function _ttyWriteDumb(s, key) { - key = key || {}; - - if (key.name === 'escape') return; - - if (this._sawReturnAt && key.name !== 'enter') - this._sawReturnAt = 0; - - if (key.ctrl && key.name === 'c') { - if (this.listenerCount('SIGINT') > 0) { - this.emit('SIGINT'); - } else { - // This readline instance is finished - this.close(); - } - } - - switch (key.name) { - case 'return': // Carriage return, i.e. \r - this._sawReturnAt = Date.now(); - this._line(); - break; - - case 'enter': - // When key interval > crlfDelay - if (this._sawReturnAt === 0 || - Date.now() - this._sawReturnAt > this.crlfDelay) { - this._line(); - } - this._sawReturnAt = 0; - break; - - default: - if (typeof s === 'string' && s) { - this.line += s; - this.cursor += s.length; - this._writeToOutput(s); - } - } -} - -// Handle a write from the tty -Interface.prototype._ttyWrite = function(s, key) { - const previousKey = this._previousKey; - key = key || {}; - this._previousKey = key; - - // Ignore escape key, fixes - // https://github.com/nodejs/node-v0.x-archive/issues/2876. - if (key.name === 'escape') return; - - if (key.ctrl && key.shift) { - /* Control and shift pressed */ - switch (key.name) { - case 'backspace': - this._deleteLineLeft(); - break; - - case 'delete': - this._deleteLineRight(); - break; - } - - } else if (key.ctrl) { - /* Control key pressed */ - - switch (key.name) { - case 'c': - if (this.listenerCount('SIGINT') > 0) { - this.emit('SIGINT'); - } else { - // This readline instance is finished - this.close(); - } - break; - - case 'h': // delete left - this._deleteLeft(); - break; - - case 'd': // delete right or EOF - if (this.cursor === 0 && this.line.length === 0) { - // This readline instance is finished - this.close(); - } else if (this.cursor < this.line.length) { - this._deleteRight(); - } - break; - - case 'u': // Delete from current to start of line - this._deleteLineLeft(); - break; - - case 'k': // Delete from current to end of line - this._deleteLineRight(); - break; - - case 'a': // Go to the start of the line - this._moveCursor(-Infinity); - break; - - case 'e': // Go to the end of the line - this._moveCursor(+Infinity); - break; - - case 'b': // back one character - this._moveCursor(-charLengthLeft(this.line, this.cursor)); - break; - - case 'f': // Forward one character - this._moveCursor(+charLengthAt(this.line, this.cursor)); - break; - - case 'l': // Clear the whole screen - cursorTo(this.output, 0, 0); - clearScreenDown(this.output); - this._refreshLine(); - break; - - case 'n': // next history item - this._historyNext(); - break; - - case 'p': // Previous history item - this._historyPrev(); - break; - - case 'z': - if (process.platform === 'win32') break; - if (this.listenerCount('SIGTSTP') > 0) { - this.emit('SIGTSTP'); - } else { - process.once('SIGCONT', (function continueProcess(self) { - return function() { - // Don't raise events if stream has already been abandoned. - if (!self.paused) { - // Stream must be paused and resumed after SIGCONT to catch - // SIGINT, SIGTSTP, and EOF. - self.pause(); - self.emit('SIGCONT'); - } - // Explicitly re-enable "raw mode" and move the cursor to - // the correct position. - // See https://github.com/joyent/node/issues/3295. - self._setRawMode(true); - self._refreshLine(); - }; - })(this)); - this._setRawMode(false); - process.kill(process.pid, 'SIGTSTP'); - } - break; - - case 'w': // Delete backwards to a word boundary - case 'backspace': - this._deleteWordLeft(); - break; - - case 'delete': // Delete forward to a word boundary - this._deleteWordRight(); - break; - - case 'left': - this._wordLeft(); - break; - - case 'right': - this._wordRight(); - break; - } - - } else if (key.meta) { - /* Meta key pressed */ - - switch (key.name) { - case 'b': // backward word - this._wordLeft(); - break; - - case 'f': // forward word - this._wordRight(); - break; - - case 'd': // delete forward word - case 'delete': - this._deleteWordRight(); - break; - - case 'backspace': // Delete backwards to a word boundary - this._deleteWordLeft(); - break; - } - - } else { - /* No modifier keys used */ - - // \r bookkeeping is only relevant if a \n comes right after. - if (this._sawReturnAt && key.name !== 'enter') - this._sawReturnAt = 0; - - switch (key.name) { - case 'return': // Carriage return, i.e. \r - this._sawReturnAt = Date.now(); - this._line(); - break; - - case 'enter': - // When key interval > crlfDelay - if (this._sawReturnAt === 0 || - Date.now() - this._sawReturnAt > this.crlfDelay) { - this._line(); - } - this._sawReturnAt = 0; - break; - - case 'backspace': - this._deleteLeft(); - break; - - case 'delete': - this._deleteRight(); - break; - - case 'left': - // Obtain the code point to the left - this._moveCursor(-charLengthLeft(this.line, this.cursor)); - break; - - case 'right': - this._moveCursor(+charLengthAt(this.line, this.cursor)); - break; - - case 'home': - this._moveCursor(-Infinity); - break; - - case 'end': - this._moveCursor(+Infinity); - break; - - case 'up': - this._historyPrev(); - break; - - case 'down': - this._historyNext(); - break; - - case 'tab': - // If tab completion enabled, do that... - if (typeof this.completer === 'function' && this.isCompletionEnabled) { - const lastKeypressWasTab = previousKey && previousKey.name === 'tab'; - this._tabComplete(lastKeypressWasTab); - break; - } - // falls through - - default: - if (typeof s === 'string' && s) { - var lines = s.split(/\r\n|\n|\r/); - for (var i = 0, len = lines.length; i < len; i++) { - if (i > 0) { - this._line(); - } - this._insertString(lines[i]); - } - } - } - } -}; - -Interface.prototype[Symbol.asyncIterator] = function() { - if (this[kLineObjectStream] === undefined) { - if (Readable === undefined) { - Readable = require('stream').Readable; - } - const readable = new Readable({ - objectMode: true, - read: () => { - this.resume(); - }, - destroy: (err, cb) => { - this.off('line', lineListener); - this.off('close', closeListener); - this.close(); - cb(err); - } - }); - const lineListener = (input) => { - if (!readable.push(input)) { - this.pause(); - } - }; - const closeListener = () => { - readable.push(null); - }; - this.on('line', lineListener); - this.on('close', closeListener); - this[kLineObjectStream] = readable; - } - - return this[kLineObjectStream][Symbol.asyncIterator](); -}; - -/** - * accepts a readable Stream instance and makes it emit "keypress" events - */ - -function emitKeypressEvents(stream, iface) { - if (stream[KEYPRESS_DECODER]) return; - - if (StringDecoder === undefined) - StringDecoder = require('string_decoder').StringDecoder; - stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); - - stream[ESCAPE_DECODER] = emitKeys(stream); - stream[ESCAPE_DECODER].next(); - - const escapeCodeTimeout = () => stream[ESCAPE_DECODER].next(''); - let timeoutId; - - function onData(b) { - if (stream.listenerCount('keypress') > 0) { - var r = stream[KEYPRESS_DECODER].write(b); - if (r) { - clearTimeout(timeoutId); - - if (iface) { - iface._sawKeyPress = r.length === 1; - } - - for (var i = 0; i < r.length; i++) { - if (r[i] === '\t' && typeof r[i + 1] === 'string' && iface) { - iface.isCompletionEnabled = false; - } - - try { - stream[ESCAPE_DECODER].next(r[i]); - // Escape letter at the tail position - if (r[i] === kEscape && i + 1 === r.length) { - timeoutId = setTimeout( - escapeCodeTimeout, - iface ? iface.escapeCodeTimeout : ESCAPE_CODE_TIMEOUT - ); - } - } catch (err) { - // If the generator throws (it could happen in the `keypress` - // event), we need to restart it. - stream[ESCAPE_DECODER] = emitKeys(stream); - stream[ESCAPE_DECODER].next(); - throw err; - } finally { - if (iface) { - iface.isCompletionEnabled = true; - } - } - } - } - } else { - // Nobody's watching anyway - stream.removeListener('data', onData); - stream.on('newListener', onNewListener); - } - } - - function onNewListener(event) { - if (event === 'keypress') { - stream.on('data', onData); - stream.removeListener('newListener', onNewListener); - } - } - - if (stream.listenerCount('keypress') > 0) { - stream.on('data', onData); - } else { - stream.on('newListener', onNewListener); - } -} - -/** - * moves the cursor to the x and y coordinate on the given stream - */ - -function cursorTo(stream, x, y) { - if (stream === null || stream === undefined) - return; - - if (typeof x !== 'number' && typeof y !== 'number') - return; - - if (typeof x !== 'number') - throw new ERR_INVALID_CURSOR_POS(); - - if (typeof y !== 'number') { - stream.write(CSI`${x + 1}G`); - } else { - stream.write(CSI`${y + 1};${x + 1}H`); - } -} - -/** - * moves the cursor relative to its current location - */ - -function moveCursor(stream, dx, dy) { - if (stream === null || stream === undefined) - return; - - if (dx < 0) { - stream.write(CSI`${-dx}D`); - } else if (dx > 0) { - stream.write(CSI`${dx}C`); - } - - if (dy < 0) { - stream.write(CSI`${-dy}A`); - } else if (dy > 0) { - stream.write(CSI`${dy}B`); - } -} - -/** - * clears the current line the cursor is on: - * -1 for left of the cursor - * +1 for right of the cursor - * 0 for the entire line - */ - -function clearLine(stream, dir) { - if (stream === null || stream === undefined) - return; - - if (dir < 0) { - // to the beginning - stream.write(kClearToBeginning); - } else if (dir > 0) { - // to the end - stream.write(kClearToEnd); - } else { - // entire line - stream.write(kClearLine); - } -} - -/** - * clears the screen from the current position of the cursor down - */ - -function clearScreenDown(stream) { - if (stream === null || stream === undefined) - return; - - stream.write(kClearScreenDown); -} + clearLine, + clearScreenDown, + cursorTo, + emitKeypressEvents, + moveCursor +} = require('internal/readline/utils'); module.exports = { Interface, @@ -1270,3 +46,31 @@ module.exports = { emitKeypressEvents, moveCursor }; + +let promises; + +Object.defineProperties(module.exports, { + promises: { + configurable: true, + enumerable: true, + get() { + if (promises === undefined) { + const { + Interface, + createInterface + } = require('internal/readline/promises'); + + promises = { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + emitKeypressEvents, + moveCursor + }; + } + return promises; + } + } +}); diff --git a/node.gyp b/node.gyp index 9a5556c2eff40d..54489ad44e0dd6 100644 --- a/node.gyp +++ b/node.gyp @@ -170,7 +170,9 @@ 'lib/internal/process/report.js', 'lib/internal/process/task_queues.js', 'lib/internal/querystring.js', - 'lib/internal/readline.js', + 'lib/internal/readline/interface.js', + 'lib/internal/readline/promises.js', + 'lib/internal/readline/utils.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js', 'lib/internal/repl/history.js', diff --git a/test/parallel/test-icu-stringwidth.js b/test/parallel/test-icu-stringwidth.js index a66ab0ce8b1e8c..0620d3af3934ca 100644 --- a/test/parallel/test-icu-stringwidth.js +++ b/test/parallel/test-icu-stringwidth.js @@ -6,7 +6,7 @@ if (!common.hasIntl) common.skip('missing Intl'); const assert = require('assert'); -const readline = require('internal/readline'); +const readline = require('internal/readline/utils'); // Test column width diff --git a/test/parallel/test-readline-csi.js b/test/parallel/test-readline-csi.js index 25d5a5eb6e79d7..ec63688c7606a3 100644 --- a/test/parallel/test-readline-csi.js +++ b/test/parallel/test-readline-csi.js @@ -5,7 +5,7 @@ const common = require('../common'); const assert = require('assert'); const readline = require('readline'); const { Writable } = require('stream'); -const { CSI } = require('internal/readline'); +const { CSI } = require('internal/readline/utils'); { assert(CSI); diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index 6c51497388575a..da48ef5a874df1 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -25,7 +25,7 @@ const common = require('../common'); const assert = require('assert'); const readline = require('readline'); -const internalReadline = require('internal/readline'); +const internalReadline = require('internal/readline/utils'); const EventEmitter = require('events').EventEmitter; const { Writable, Readable } = require('stream'); diff --git a/test/parallel/test-readline-promises-interface.js b/test/parallel/test-readline-promises-interface.js new file mode 100644 index 00000000000000..a703ae263a15ef --- /dev/null +++ b/test/parallel/test-readline-promises-interface.js @@ -0,0 +1,1230 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const readline = require('readline').promises; +const EventEmitter = require('events').EventEmitter; +const { Writable, Readable } = require('stream'); + +class FakeInput extends EventEmitter { + resume() {} + pause() {} + write() {} + end() {} +} + +function isWarned(emitter) { + for (const name in emitter) { + const listeners = emitter[name]; + if (listeners.warned) return true; + } + return false; +} + + +{ + // Default crlfDelay is 100ms + const fi = new FakeInput(); + const rli = new readline.Interface({ input: fi, output: fi }); + assert.strictEqual(rli.crlfDelay, 100); + rli.close(); +} + +{ + // Minimum crlfDelay is 100ms + const fi = new FakeInput(); + const rli = new readline.Interface({ input: fi, output: fi, crlfDelay: 0 }); + assert.strictEqual(rli.crlfDelay, 100); + rli.close(); +} + +{ + // Set crlfDelay to float 100.5ms + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + crlfDelay: 100.5 + }); + assert.strictEqual(rli.crlfDelay, 100.5); + rli.close(); +} + +{ + // Set crlfDelay to 5000ms + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + crlfDelay: 5000 + }); + assert.strictEqual(rli.crlfDelay, 5000); + rli.close(); +} + +[ true, false ].forEach(function(terminal) { + // disable history + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal, historySize: 0 } + ); + assert.strictEqual(rli.historySize, 0); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? [] : undefined); + rli.close(); + } + + // Default history size 30 + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + assert.strictEqual(rli.historySize, 30); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? ['asdf'] : undefined); + rli.close(); + } + + // sending a full line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, 'asdf'); + }); + fi.emit('data', 'asdf\n'); + assert.ok(called); + } + + // Sending a blank line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, ''); + }); + fi.emit('data', '\n'); + assert.ok(called); + } + + // Sending a single character with no newline + { + const fi = new FakeInput(); + const rli = new readline.Interface(fi, {}); + let called = false; + rli.on('line', function(line) { + called = true; + }); + fi.emit('data', 'a'); + assert.ok(!called); + rli.close(); + } + + // Sending a single character with no newline and then a newline + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, 'a'); + }); + fi.emit('data', 'a'); + assert.ok(!called); + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // Sending multiple newlines at once + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', `${expectedLines.join('\n')}\n`); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\n')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new(empty) + // line and a `end` event + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar', 'baz', '']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + rli.on('close', function() { + callCount++; + }); + fi.emit('data', expectedLines.join('\n')); + fi.emit('end'); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new line + // and a `end` event(last line is) + + // \r should behave like \n when alone + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\r')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // \r at start of input should output blank line + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const expectedLines = ['', 'foo' ]; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', '\rfoo\r'); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Emit two line events when the delay + // between \r and \n exceeds crlfDelay + { + const fi = new FakeInput(); + const delay = 200; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay: delay + }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + }); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 2); + rli.close(); + }), delay * 2); + } + + // Set crlfDelay to `Infinity` is allowed + { + const fi = new FakeInput(); + const delay = 200; + const crlfDelay = Infinity; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay + }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + }); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + }), delay); + } + + // \t when there is no completer function should behave like an ordinary + // character + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + let called = false; + rli.on('line', function(line) { + assert.strictEqual(line, '\t'); + assert.strictEqual(called, false); + called = true; + }); + fi.emit('data', '\t'); + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // \t does not become part of the input when there is a completer function + { + const fi = new FakeInput(); + const completer = (line) => { + return new Promise(common.mustCall((resolve) => { + resolve([[], line]); + })); + }; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer + }); + let called = false; + rli.on('line', function(line) { + assert.strictEqual(line, 'foo'); + assert.strictEqual(called, false); + called = true; + }); + for (const character of '\tfo\to\t') { + fi.emit('data', character); + } + fi.emit('data', '\n'); + assert.ok(called); + rli.close(); + } + + // Constructor throws if completer is not a function or undefined + { + const fi = new FakeInput(); + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: 'string is not valid' + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: '' + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, + completer: false + }); + }, { + type: TypeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + } + + // Constructor throws if historySize is not a positive number + { + const fi = new FakeInput(); + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: 'not a number' + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: -1 + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + + common.expectsError(function() { + readline.createInterface({ + input: fi, historySize: NaN + }); + }, { + type: RangeError, + code: 'ERR_INVALID_OPT_VALUE' + }); + } + + // Duplicate lines are removed from history when + // `options.removeHistoryDuplicates` is `true` + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + removeHistoryDuplicates: true + }); + const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', `${expectedLines.join('\n')}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'foo' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + fi.emit('keypress', '.', { name: 'down' }); // 'baz' + assert.strictEqual(rli.line, 'baz'); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); // 'bar' + assert.strictEqual(rli.line, 'bar'); + fi.emit('keypress', '.', { name: 'down' }); // 'bat' + assert.strictEqual(rli.line, 'bat'); + fi.emit('keypress', '.', { name: 'down' }); // '' + assert.strictEqual(rli.line, ''); + rli.close(); + } + + // Duplicate lines are not removed from history when + // `options.removeHistoryDuplicates` is `false` + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + removeHistoryDuplicates: false + }); + const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', `${expectedLines.join('\n')}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit('keypress', '.', { name: 'up' }); // 'foo' + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + rli.close(); + } + + // Sending a multi-byte utf8 char over multiple writes + { + const buf = Buffer.from('☮', 'utf8'); + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + assert.strictEqual(line, buf.toString('utf8')); + }); + [].forEach.call(buf, function(i) { + fi.emit('data', Buffer.from([i])); + }); + assert.strictEqual(callCount, 0); + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + } + + // Regression test for repl freeze, #1968: + // check that nothing fails if 'keypress' event throws. + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: true } + ); + const keys = []; + fi.on('keypress', function(key) { + keys.push(key); + if (key === 'X') { + throw new Error('bad thing happened'); + } + }); + try { + fi.emit('data', 'fooX'); + } catch { } + fi.emit('data', 'bar'); + assert.strictEqual(keys.join(''), 'fooXbar'); + rli.close(); + } + + // Calling the question callback + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + + rli.question('foo?').then(common.mustCall((answer) => { + assert.strictEqual(answer, 'bar'); + })).catch(common.mustNotCall()); + + rli.write('bar\n'); + rli.close(); + } + + if (terminal) { + // history is bound + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal, historySize: 2 } + ); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n') + '\n'); + assert.strictEqual(rli.history.length, 2); + assert.strictEqual(rli.history[0], 'line 3'); + assert.strictEqual(rli.history[1], 'line 2'); + } + // question + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo']; + rli.question(expectedLines[0]).catch(common.mustNotCall()); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, expectedLines[0].length); + rli.close(); + } + + // Sending a multi-line question + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: fi, terminal: terminal } + ); + const expectedLines = ['foo', 'bar']; + rli.question(expectedLines.join('\n')).catch(common.mustNotCall()); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, expectedLines.length - 1); + assert.strictEqual(cursorPos.cols, expectedLines.slice(-1)[0].length); + rli.close(); + } + + { + // Beginning and end of line + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + fi.emit('keypress', '.', { ctrl: true, name: 'e' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + { + // Back and Forward one character + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 17); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + // Back and Forward one astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Move right one character/code point + fi.emit('keypress', '.', { name: 'right' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // Two astral characters left + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + fi.emit('data', '🐕'); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + // Fix cursor position without internationalization + fi.emit('keypress', '.', { name: 'left' }); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '🐕💻'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // Two astral characters right + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'right' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + // Fix cursor position without internationalization + fi.emit('keypress', '.', { name: 'right' }); + } + + fi.emit('data', '🐕'); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 4); + } else { + assert.strictEqual(cursorPos.cols, 2); + } + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻🐕'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + { + // `wordLeft` and `wordRight` + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 16); + fi.emit('keypress', '.', { meta: true, name: 'b' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 10); + fi.emit('keypress', '.', { ctrl: true, name: 'right' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 16); + fi.emit('keypress', '.', { meta: true, name: 'f' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + rli.close(); + } + + { + // `deleteWordLeft` + [ + { ctrl: true, name: 'w' }, + { ctrl: true, name: 'backspace' }, + { meta: true, name: 'backspace' } + ] + .forEach((deleteWordLeftKey) => { + let fi = new FakeInput(); + let rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick fox'); + })); + fi.emit('keypress', '.', deleteWordLeftKey); + fi.emit('data', '\n'); + rli.close(); + + // No effect if pressed at beginning of line + fi = new FakeInput(); + rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fox'); + })); + fi.emit('keypress', '.', deleteWordLeftKey); + fi.emit('data', '\n'); + rli.close(); + }); + } + + { + // `deleteWordRight` + [ + { ctrl: true, name: 'delete' }, + { meta: true, name: 'delete' }, + { meta: true, name: 'd' } + ] + .forEach((deleteWordRightKey) => { + let fi = new FakeInput(); + let rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick fox'); + })); + fi.emit('keypress', '.', deleteWordRightKey); + fi.emit('data', '\n'); + rli.close(); + + // No effect if pressed at end of line + fi = new FakeInput(); + rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fox'); + })); + fi.emit('keypress', '.', deleteWordRightKey); + fi.emit('data', '\n'); + rli.close(); + }); + } + + // deleteLeft + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 18); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fo'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLeft astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + if (common.hasIntl) { + assert.strictEqual(cursorPos.cols, 2); + } else { + assert.strictEqual(cursorPos.cols, 1); + } + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteRight + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'he quick brown fox'); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteRight astral character + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', '💻'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLineLeft + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 19); + + // Delete from current to start of line + fi.emit('keypress', '.', + { ctrl: true, shift: true, name: 'backspace' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // deleteLineRight + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + let cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + + // Delete from current to end of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); + } + + // multi-line cursor position + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + fi.columns = 10; + fi.emit('data', 'multi-line text'); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 1); + assert.strictEqual(cursorPos.cols, 5); + rli.close(); + } + + // Clear the whole screen + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + prompt: '', + terminal: terminal + }); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n')); + fi.emit('keypress', '.', { ctrl: true, name: 'l' }); + const cursorPos = rli._getCursorPos(); + assert.strictEqual(cursorPos.rows, 0); + assert.strictEqual(cursorPos.cols, 6); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'line 3'); + })); + fi.emit('data', '\n'); + rli.close(); + } + } + + { + const fi = new FakeInput(); + assert.deepStrictEqual(fi.listeners(terminal ? 'keypress' : 'data'), []); + } + + // check EventEmitter memory leak + for (let i = 0; i < 12; i++) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + rl.close(); + assert.strictEqual(isWarned(process.stdin._events), false); + assert.strictEqual(isWarned(process.stdout._events), false); + } + + // Can create a new readline Interface with a null output argument + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { input: fi, output: null, terminal: terminal } + ); + + let called = false; + rli.on('line', function(line) { + called = true; + assert.strictEqual(line, 'asdf'); + }); + fi.emit('data', 'asdf\n'); + assert.ok(called); + + rli.setPrompt('ddd> '); + rli.prompt(); + rli.write('really shouldnt be seeing this'); + rli.question('What do you think of node.js? ') + .then(common.mustNotCall()) + .catch(common.mustNotCall()); + } + + { + const expected = terminal ? + ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] : + ['$ ']; + + let counter = 0; + const output = new Writable({ + write: common.mustCall((chunk, enc, cb) => { + assert.strictEqual(chunk.toString(), expected[counter++]); + cb(); + rl.close(); + }, expected.length) + }); + + const rl = readline.createInterface({ + input: new Readable({ read: common.mustCall() }), + output: output, + prompt: '$ ', + terminal: terminal + }); + + rl.prompt(); + + assert.strictEqual(rl._prompt, '$ '); + } +}); + +// For the purposes of the following tests, we do not care about the exact +// value of crlfDelay, only that the behaviour conforms to what's expected. +// Setting it to Infinity allows the test to succeed even under extreme +// CPU stress. +const crlfDelay = Infinity; + +[ true, false ].forEach(function(terminal) { + // Sending multiple newlines at once that does not end with a new line + // and a `end` event(last line is) + + // \r\n should emit one line event, not two + { + const fi = new FakeInput(); + const rli = new readline.Interface( + { + input: fi, + output: fi, + terminal: terminal, + crlfDelay + } + ); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit('data', expectedLines.join('\r\n')); + assert.strictEqual(callCount, expectedLines.length - 1); + rli.close(); + } + + // \r\n should emit one line event when split across multiple writes. + { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay + }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', function(line) { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + expectedLines.forEach(function(line) { + fi.emit('data', `${line}\r`); + fi.emit('data', '\n'); + }); + assert.strictEqual(callCount, expectedLines.length); + rli.close(); + } + + // Emit one line event when the delay between \r and \n is + // over the default crlfDelay but within the setting value. + { + const fi = new FakeInput(); + const delay = 125; + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: terminal, + crlfDelay + }); + let callCount = 0; + rli.on('line', () => callCount++); + fi.emit('data', '\r'); + setTimeout(common.mustCall(() => { + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + }), delay); + } +}); + +// Ensure that the _wordLeft method works even for large input +{ + const input = new Readable({ + read() { + this.push('\x1B[1;5D'); // CTRL + Left + this.push(null); + }, + }); + const output = new Writable({ + write: common.mustCall((data, encoding, cb) => { + assert.strictEqual(rl.cursor, rl.line.length - 1); + cb(); + }), + }); + const rl = new readline.createInterface({ + input: input, + output: output, + terminal: true, + }); + rl.line = `a${' '.repeat(1e6)}a`; + rl.cursor = rl.line.length; +} diff --git a/test/parallel/test-readline-promises.js b/test/parallel/test-readline-promises.js new file mode 100644 index 00000000000000..eb0fe34c7f6707 --- /dev/null +++ b/test/parallel/test-readline-promises.js @@ -0,0 +1,185 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const readline = require('readline'); +const { PassThrough } = require('stream'); +const promises = readline.promises; + +{ + // Verify the shape of the promises API. + assert.strictEqual(promises.clearLine, readline.clearLine); + assert.strictEqual(promises.clearScreenDown, readline.clearScreenDown); + assert.strictEqual(promises.cursorTo, readline.cursorTo); + assert.strictEqual(promises.emitKeypressEvents, readline.emitKeypressEvents); + assert.strictEqual(promises.moveCursor, readline.moveCursor); + assert.strictEqual(typeof promises.createInterface, 'function'); + assert.notStrictEqual(promises.createInterface, readline.createInterface); + assert.strictEqual(typeof promises.Interface, 'function'); + assert.notStrictEqual(promises.Interface, readline.Interface); + + assert.strictEqual(promises.Interface.prototype.close, + readline.Interface.prototype.close); + assert.strictEqual(promises.Interface.prototype.pause, + readline.Interface.prototype.pause); + assert.strictEqual(promises.Interface.prototype.prompt, + readline.Interface.prototype.prompt); + assert.strictEqual(promises.Interface.prototype.resume, + readline.Interface.prototype.resume); + assert.strictEqual(promises.Interface.prototype.setPrompt, + readline.Interface.prototype.setPrompt); + assert.strictEqual(promises.Interface.prototype.write, + readline.Interface.prototype.write); + assert.strictEqual(promises.Interface.prototype[Symbol.asyncIterator], + readline.Interface.prototype[Symbol.asyncIterator]); + assert.strictEqual(typeof promises.Interface.prototype.question, 'function'); + assert.notStrictEqual(promises.Interface.prototype.question, + readline.Interface.prototype.question); + assert.strictEqual(typeof promises.Interface.prototype._tabComplete, + 'function'); + assert.notStrictEqual(promises.Interface.prototype._tabComplete, + readline.Interface.prototype._tabComplete); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustCall((data) => { + assert.strictEqual(data, 'abc'); + })); + + input.end('abc'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustNotCall('must not be called before newline')); + + input.write('abc'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.on('line', common.mustCall((data) => { + assert.strictEqual(data, 'abc'); + })); + + input.write('abc\n'); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + rl.write('foo'); + assert.strictEqual(rl.cursor, 3); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + end: ['\x1b[F', { ctrl: true, name: 'e' }], + }, + gnome: { + home: ['\x1bOH', { ctrl: true, name: 'a' }], + end: ['\x1bOF', { ctrl: true, name: 'e' }] + }, + rxvt: { + home: ['\x1b[7', { ctrl: true, name: 'a' }], + end: ['\x1b[8', { ctrl: true, name: 'e' }] + }, + putty: { + home: ['\x1b[1~', { ctrl: true, name: 'a' }], + end: ['\x1b[>~', { ctrl: true, name: 'e' }] + } + }; + + [key.xterm, key.gnome, key.rxvt, key.putty].forEach(function(key) { + rl.write.apply(rl, key.home); + assert.strictEqual(rl.cursor, 0); + rl.write.apply(rl, key.end); + assert.strictEqual(rl.cursor, 3); + }); + +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + metab: ['\x1bb', { meta: true, name: 'b' }], + metaf: ['\x1bf', { meta: true, name: 'f' }], + } + }; + + rl.write('foo bar.hop/zoo'); + rl.write.apply(rl, key.xterm.home); + [ + { cursor: 4, key: key.xterm.metaf }, + { cursor: 7, key: key.xterm.metaf }, + { cursor: 8, key: key.xterm.metaf }, + { cursor: 11, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metaf }, + { cursor: 15, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metab }, + { cursor: 11, key: key.xterm.metab }, + { cursor: 8, key: key.xterm.metab }, + { cursor: 7, key: key.xterm.metab }, + { cursor: 4, key: key.xterm.metab }, + { cursor: 0, key: key.xterm.metab }, + ].forEach(function(action) { + rl.write.apply(rl, action.key); + assert.strictEqual(rl.cursor, action.cursor); + }); +} + +{ + const input = new PassThrough(); + const rl = promises.createInterface({ + terminal: true, + input: input + }); + + const key = { + xterm: { + home: ['\x1b[H', { ctrl: true, name: 'a' }], + metad: ['\x1bd', { meta: true, name: 'd' }] + } + }; + + rl.write('foo bar.hop/zoo'); + rl.write.apply(rl, key.xterm.home); + [ + 'bar.hop/zoo', + '.hop/zoo', + 'hop/zoo', + '/zoo', + 'zoo', + '' + ].forEach(function(expectedLine) { + rl.write.apply(rl, key.xterm.metad); + assert.strictEqual(rl.cursor, 0); + assert.strictEqual(rl.line, expectedLine); + }); +} diff --git a/test/parallel/test-repl-top-level-await.js b/test/parallel/test-repl-top-level-await.js index 73023df038b2c9..f72a5daa01581f 100644 --- a/test/parallel/test-repl-top-level-await.js +++ b/test/parallel/test-repl-top-level-await.js @@ -3,7 +3,7 @@ require('../common'); const ArrayStream = require('../common/arraystream'); const assert = require('assert'); -const { stripVTControlCharacters } = require('internal/readline'); +const { stripVTControlCharacters } = require('internal/readline/utils'); const repl = require('repl'); // Flags: --expose-internals --experimental-repl-await diff --git a/tools/doc/type-parser.js b/tools/doc/type-parser.js index 22307bdf6e4d3c..b9323e7cc04dd4 100644 --- a/tools/doc/type-parser.js +++ b/tools/doc/type-parser.js @@ -115,6 +115,8 @@ const customTypesMap = { 'perf_hooks.html#perf_hooks_class_performanceobserverentrylist', 'readline.Interface': 'readline.html#readline_class_interface', + 'readline.promises.Interface': + 'readline.html#readline_class_readline_promises_interface', 'repl.REPLServer': 'repl.html#repl_class_replserver',