From 592d1c3d4442c3b0d6d747a7dc02797e63f97702 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 27 Apr 2021 16:13:38 +0200 Subject: [PATCH 1/4] readline: refactor `Interface` to ES2015 class PR-URL: https://github.com/nodejs/node/pull/37947 Fixes: https://github.com/nodejs/node/issues/37287 Reviewed-By: Matteo Collina Reviewed-By: Benjamin Gruenbaum Reviewed-By: Robert Nagy --- lib/internal/readline/emitKeypressEvents.js | 5 +- lib/internal/readline/interface.js | 1261 ++++++++++++++++ lib/readline.js | 1440 ++++--------------- 3 files changed, 1551 insertions(+), 1155 deletions(-) create mode 100644 lib/internal/readline/interface.js diff --git a/lib/internal/readline/emitKeypressEvents.js b/lib/internal/readline/emitKeypressEvents.js index 9c5a2554de9d22..1ac1091d70cab2 100644 --- a/lib/internal/readline/emitKeypressEvents.js +++ b/lib/internal/readline/emitKeypressEvents.js @@ -10,6 +10,9 @@ const { CSI, emitKeys, } = require('internal/readline/utils'); +const { + kSawKeyPress, +} = require('internal/readline/interface'); const { clearTimeout, setTimeout } = require('timers'); const { @@ -47,7 +50,7 @@ function emitKeypressEvents(stream, iface = {}) { clearTimeout(timeoutId); // This supports characters of length 2. - iface._sawKeyPress = charLengthAt(string, 0) === string.length; + iface[kSawKeyPress] = charLengthAt(string, 0) === string.length; iface.isCompletionEnabled = false; let length = 0; diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js new file mode 100644 index 00000000000000..2a883589537566 --- /dev/null +++ b/lib/internal/readline/interface.js @@ -0,0 +1,1261 @@ +'use strict'; + +const { + ArrayFrom, + ArrayPrototypeFilter, + ArrayPrototypeIndexOf, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePop, + ArrayPrototypeReverse, + ArrayPrototypeSplice, + ArrayPrototypeUnshift, + DateNow, + FunctionPrototypeCall, + MathCeil, + MathFloor, + MathMax, + MathMaxApply, + NumberIsFinite, + NumberIsNaN, + ObjectSetPrototypeOf, + RegExpPrototypeTest, + StringPrototypeCodePointAt, + StringPrototypeEndsWith, + StringPrototypeMatch, + StringPrototypeRepeat, + StringPrototypeReplace, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + StringPrototypeTrim, + Symbol, + SymbolAsyncIterator, + SafeStringIterator, +} = primordials; + +const { codes } = require('internal/errors'); + +const { ERR_INVALID_ARG_VALUE } = codes; +const { + validateAbortSignal, + validateArray, + validateString, + validateUint32, +} = require('internal/validators'); +const { + inspect, + getStringWidth, + stripVTControlCharacters, +} = require('internal/util/inspect'); +const EventEmitter = require('events'); +const { + charLengthAt, + charLengthLeft, + commonPrefix, + kSubstringSearch, +} = require('internal/readline/utils'); +let emitKeypressEvents; +const { + clearScreenDown, + cursorTo, + moveCursor, +} = require('internal/readline/callbacks'); + +const { StringDecoder } = require('string_decoder'); + +// 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 kQuestionCancel = Symbol('kQuestionCancel'); + +// GNU readline library - keyseq-timeout is 500ms (default) +const ESCAPE_CODE_TIMEOUT = 500; + +const kAddHistory = Symbol('_addHistory'); +const kDecoder = Symbol('_decoder'); +const kDeleteLeft = Symbol('_deleteLeft'); +const kDeleteLineLeft = Symbol('_deleteLineLeft'); +const kDeleteLineRight = Symbol('_deleteLineRight'); +const kDeleteRight = Symbol('_deleteRight'); +const kDeleteWordLeft = Symbol('_deleteWordLeft'); +const kDeleteWordRight = Symbol('_deleteWordRight'); +const kGetDisplayPos = Symbol('_getDisplayPos'); +const kHistoryNext = Symbol('_historyNext'); +const kHistoryPrev = Symbol('_historyPrev'); +const kInsertString = Symbol('_insertString'); +const kLine = Symbol('_line'); +const kLine_buffer = Symbol('_line_buffer'); +const kMoveCursor = Symbol('_moveCursor'); +const kNormalWrite = Symbol('_normalWrite'); +const kOldPrompt = Symbol('_oldPrompt'); +const kOnLine = Symbol('_onLine'); +const kPreviousKey = Symbol('_previousKey'); +const kPrompt = Symbol('_prompt'); +const kQuestionCallback = Symbol('_questionCallback'); +const kRefreshLine = Symbol('_refreshLine'); +const kSawKeyPress = Symbol('_sawKeyPress'); +const kSawReturnAt = Symbol('_sawReturnAt'); +const kSetRawMode = Symbol('_setRawMode'); +const kTabComplete = Symbol('_tabComplete'); +const kTabCompleter = Symbol('_tabCompleter'); +const kTtyWrite = Symbol('_ttyWrite'); +const kWordLeft = Symbol('_wordLeft'); +const kWordRight = Symbol('_wordRight'); +const kWriteToOutput = Symbol('_writeToOutput'); + +function InterfaceConstructor(input, output, completer, terminal) { + this[kSawReturnAt] = 0; + // TODO(BridgeAR): Document this property. The name is not ideal, so we + // might want to expose an alias and document that instead. + this.isCompletionEnabled = true; + this[kSawKeyPress] = false; + this[kPreviousKey] = null; + this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT; + this.tabSize = 8; + + FunctionPrototypeCall(EventEmitter, this); + + let history; + let historySize; + let removeHistoryDuplicates = false; + let crlfDelay; + let prompt = '> '; + let signal; + + if (input?.input) { + // An options object was given + output = input.output; + completer = input.completer; + terminal = input.terminal; + history = input.history; + historySize = input.historySize; + signal = input.signal; + if (input.tabSize !== undefined) { + validateUint32(input.tabSize, 'tabSize', true); + this.tabSize = input.tabSize; + } + removeHistoryDuplicates = input.removeHistoryDuplicates; + if (input.prompt !== undefined) { + prompt = input.prompt; + } + if (input.escapeCodeTimeout !== undefined) { + if (NumberIsFinite(input.escapeCodeTimeout)) { + this.escapeCodeTimeout = input.escapeCodeTimeout; + } else { + throw new ERR_INVALID_ARG_VALUE( + 'input.escapeCodeTimeout', + this.escapeCodeTimeout + ); + } + } + + if (signal) { + validateAbortSignal(signal, 'options.signal'); + } + + crlfDelay = input.crlfDelay; + input = input.input; + } + + if (completer !== undefined && typeof completer !== 'function') { + throw new ERR_INVALID_ARG_VALUE('completer', completer); + } + + if (history === undefined) { + history = []; + } else { + validateArray(history, 'history'); + } + + if (historySize === undefined) { + historySize = kHistorySize; + } + + if ( + typeof historySize !== 'number' || + NumberIsNaN(historySize) || + historySize < 0 + ) { + throw new ERR_INVALID_ARG_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.line = ''; + this[kSubstringSearch] = null; + this.output = output; + this.input = input; + this.history = history; + this.historySize = historySize; + this.removeHistoryDuplicates = !!removeHistoryDuplicates; + this.crlfDelay = crlfDelay ? + MathMax(kMincrlfDelay, crlfDelay) : + kMincrlfDelay; + this.completer = completer; + + this.setPrompt(prompt); + + this.terminal = !!terminal; + + + function onerror(err) { + self.emit('error', err); + } + + function ondata(data) { + self[kNormalWrite](data); + } + + function onend() { + if ( + typeof self[kLine_buffer] === 'string' && + self[kLine_buffer].length > 0 + ) { + self.emit('line', self[kLine_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[kTtyWrite](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 = StringPrototypeCodePointAt(key.sequence, 0); + if (ch >= 0xd800 && ch <= 0xdfff) self[kRefreshLine](); + } + } + + function onresize() { + self[kRefreshLine](); + } + + this[kLineObjectStream] = undefined; + + input.on('error', onerror); + + if (!this.terminal) { + function onSelfCloseWithoutTerminal() { + input.removeListener('data', ondata); + input.removeListener('error', onerror); + input.removeListener('end', onend); + } + + input.on('data', ondata); + input.on('end', onend); + self.once('close', onSelfCloseWithoutTerminal); + this[kDecoder] = new StringDecoder('utf8'); + } else { + function onSelfCloseWithTerminal() { + input.removeListener('keypress', onkeypress); + input.removeListener('error', onerror); + input.removeListener('end', ontermend); + if (output !== null && output !== undefined) { + output.removeListener('resize', onresize); + } + } + + emitKeypressEvents ??= require('internal/readline/emitKeypressEvents'); + emitKeypressEvents(input, this); + + // `input` usually refers to stdin + input.on('keypress', onkeypress); + input.on('end', ontermend); + + this[kSetRawMode](true); + this.terminal = true; + + // Cursor position on the line. + this.cursor = 0; + + this.historyIndex = -1; + + if (output !== null && output !== undefined) + output.on('resize', onresize); + + self.once('close', onSelfCloseWithTerminal); + } + + if (signal) { + const onAborted = () => self.close(); + if (signal.aborted) { + process.nextTick(onAborted); + } else { + signal.addEventListener('abort', onAborted, { once: true }); + self.once('close', () => signal.removeEventListener('abort', onAborted)); + } + } + + // Current line + this.line = ''; + + input.resume(); +} + +ObjectSetPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype); +ObjectSetPrototypeOf(InterfaceConstructor, EventEmitter); + +class Interface extends InterfaceConstructor { + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + get columns() { + if (this.output && this.output.columns) return this.output.columns; + return Infinity; + } + + /** + * Sets the prompt written to the output. + * @param {string} prompt + * @returns {void} + */ + setPrompt(prompt) { + this[kPrompt] = prompt; + } + + /** + * Returns the current prompt used by `rl.prompt()`. + * @returns {string} + */ + getPrompt() { + return this[kPrompt]; + } + + [kSetRawMode](mode) { + const wasInRawMode = this.input.isRaw; + + if (typeof this.input.setRawMode === 'function') { + this.input.setRawMode(mode); + } + + return wasInRawMode; + } + + /** + * Writes the configured `prompt` to a new line in `output`. + * @param {boolean} [preserveCursor] + * @returns {void} + */ + prompt(preserveCursor) { + if (this.paused) this.resume(); + if (this.terminal && process.env.TERM !== 'dumb') { + if (!preserveCursor) this.cursor = 0; + this[kRefreshLine](); + } else { + this[kWriteToOutput](this[kPrompt]); + } + } + + question(query, cb) { + if (this[kQuestionCallback]) { + this.prompt(); + } else { + this[kOldPrompt] = this[kPrompt]; + this.setPrompt(query); + this[kQuestionCallback] = cb; + this.prompt(); + } + } + + [kOnLine](line) { + if (this[kQuestionCallback]) { + const cb = this[kQuestionCallback]; + this[kQuestionCallback] = null; + this.setPrompt(this[kOldPrompt]); + cb(line); + } else { + this.emit('line', line); + } + } + + [kQuestionCancel]() { + if (this[kQuestionCallback]) { + this[kQuestionCallback] = null; + this.setPrompt(this[kOldPrompt]); + this.clearLine(); + } + } + + [kWriteToOutput](stringToWrite) { + validateString(stringToWrite, 'stringToWrite'); + + if (this.output !== null && this.output !== undefined) { + this.output.write(stringToWrite); + } + } + + [kAddHistory]() { + 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 (StringPrototypeTrim(this.line).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 = ArrayPrototypeIndexOf(this.history, this.line); + if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1); + } + + ArrayPrototypeUnshift(this.history, this.line); + + // Only store so many + if (this.history.length > this.historySize) + ArrayPrototypePop(this.history); + } + + this.historyIndex = -1; + + // The listener could change the history object, possibly + // to remove the last added entry if it is sensitive and should + // not be persisted in the history, like a password + const line = this.history[0]; + + // Emit history event to notify listeners of update + this.emit('history', this.history); + + return line; + } + + [kRefreshLine]() { + // line length + const line = this[kPrompt] + this.line; + const dispPos = this[kGetDisplayPos](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[kWriteToOutput](line); + + // Force terminal to allocate a new line + if (lineCols === 0) { + this[kWriteToOutput](' '); + } + + // 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; + } + + /** + * Closes the `readline.Interface` instance. + * @returns {void} + */ + close() { + if (this.closed) return; + this.pause(); + if (this.terminal) { + this[kSetRawMode](false); + } + this.closed = true; + this.emit('close'); + } + + /** + * Pauses the `input` stream. + * @returns {void | Interface} + */ + pause() { + if (this.paused) return; + this.input.pause(); + this.paused = true; + this.emit('pause'); + return this; + } + + /** + * Resumes the `input` stream if paused. + * @returns {void | Interface} + */ + resume() { + if (!this.paused) return; + this.input.resume(); + this.paused = false; + this.emit('resume'); + return this; + } + + /** + * Writes either `data` or a `key` sequence identified by + * `key` to the `output`. + * @param {string} d + * @param {{ + * ctrl?: boolean; + * meta?: boolean; + * shift?: boolean; + * name?: string; + * }} [key] + * @returns {void} + */ + write(d, key) { + if (this.paused) this.resume(); + if (this.terminal) { + this[kTtyWrite](d, key); + } else { + this[kNormalWrite](d); + } + } + + [kNormalWrite](b) { + if (b === undefined) { + return; + } + let string = this[kDecoder].write(b); + if ( + this[kSawReturnAt] && + DateNow() - this[kSawReturnAt] <= this.crlfDelay + ) { + string = StringPrototypeReplace(string, /^\n/, ''); + this[kSawReturnAt] = 0; + } + + // Run test() on the new string chunk, not on the entire line buffer. + const newPartContainsEnding = RegExpPrototypeTest(lineEnding, string); + + if (this[kLine_buffer]) { + string = this[kLine_buffer] + string; + this[kLine_buffer] = null; + } + if (newPartContainsEnding) { + this[kSawReturnAt] = StringPrototypeEndsWith(string, '\r') ? + DateNow() : + 0; + + // Got one or more newlines; process into "line" events + const lines = StringPrototypeSplit(string, lineEnding); + // Either '' or (conceivably) the unfinished portion of the next line + string = ArrayPrototypePop(lines); + this[kLine_buffer] = string; + for (let n = 0; n < lines.length; n++) this[kOnLine](lines[n]); + } else if (string) { + // No newlines this time, save what we have for next time + this[kLine_buffer] = string; + } + } + + [kInsertString](c) { + if (this.cursor < this.line.length) { + const beg = StringPrototypeSlice(this.line, 0, this.cursor); + const end = StringPrototypeSlice( + this.line, + this.cursor, + this.line.length + ); + this.line = beg + c + end; + this.cursor += c.length; + this[kRefreshLine](); + } else { + this.line += c; + this.cursor += c.length; + + if (this.getCursorPos().cols === 0) { + this[kRefreshLine](); + } else { + this[kWriteToOutput](c); + } + } + } + + async [kTabComplete](lastKeypressWasTab) { + this.pause(); + const string = StringPrototypeSlice(this.line, 0, this.cursor); + let value; + try { + value = await this.completer(string); + } catch (err) { + this[kWriteToOutput](`Tab completion error: ${inspect(err)}`); + return; + } finally { + this.resume(); + } + this[kTabCompleter](lastKeypressWasTab, value); + } + + [kTabCompleter](lastKeypressWasTab, { 0: completions, 1: completeOn }) { + // Result and the text that was completed. + + if (!completions || completions.length === 0) { + return; + } + + // If there is a common prefix to all matches, then apply that portion. + const prefix = commonPrefix( + ArrayPrototypeFilter(completions, (e) => e !== '') + ); + if (StringPrototypeStartsWith(prefix, completeOn) && + prefix.length > completeOn.length) { + this[kInsertString](StringPrototypeSlice(prefix, completeOn.length)); + return; + } else if (!StringPrototypeStartsWith(completeOn, prefix)) { + this.line = StringPrototypeSlice(this.line, + 0, + this.cursor - completeOn.length) + + prefix + + StringPrototypeSlice(this.line, + this.cursor, + this.line.length); + this.cursor = this.cursor - completeOn.length + prefix.length; + this._refreshLine(); + return; + } + + if (!lastKeypressWasTab) { + return; + } + + // Apply/show completions. + const completionsWidth = ArrayPrototypeMap(completions, (e) => + getStringWidth(e) + ); + const width = MathMaxApply(completionsWidth) + 2; // 2 space padding + let maxColumns = MathFloor(this.columns / width) || 1; + if (maxColumns === Infinity) { + maxColumns = 1; + } + let output = '\r\n'; + let lineIndex = 0; + let whitespace = 0; + for (let i = 0; i < completions.length; i++) { + const completion = completions[i]; + if (completion === '' || lineIndex === maxColumns) { + output += '\r\n'; + lineIndex = 0; + whitespace = 0; + } else { + output += StringPrototypeRepeat(' ', whitespace); + } + if (completion !== '') { + output += completion; + whitespace = width - completionsWidth[i]; + lineIndex++; + } else { + output += '\r\n'; + } + } + if (lineIndex !== 0) { + output += '\r\n\r\n'; + } + this[kWriteToOutput](output); + this[kRefreshLine](); + } + + [kWordLeft]() { + if (this.cursor > 0) { + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + const leading = StringPrototypeSlice(this.line, 0, this.cursor); + const reversed = ArrayPrototypeJoin( + ArrayPrototypeReverse(ArrayFrom(leading)), + '' + ); + const match = StringPrototypeMatch(reversed, /^\s*(?:[^\w\s]+|\w+)?/); + this[kMoveCursor](-match[0].length); + } + } + + [kWordRight]() { + if (this.cursor < this.line.length) { + const trailing = StringPrototypeSlice(this.line, this.cursor); + const match = StringPrototypeMatch(trailing, /^(?:\s+|[^\w\s]+|\w+)\s*/); + this[kMoveCursor](match[0].length); + } + } + + [kDeleteLeft]() { + 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 = + StringPrototypeSlice(this.line, 0, this.cursor - charSize) + + StringPrototypeSlice(this.line, this.cursor, this.line.length); + + this.cursor -= charSize; + this[kRefreshLine](); + } + } + + [kDeleteRight]() { + 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 = + StringPrototypeSlice(this.line, 0, this.cursor) + + StringPrototypeSlice( + this.line, + this.cursor + charSize, + this.line.length + ); + this[kRefreshLine](); + } + } + + [kDeleteWordLeft]() { + if (this.cursor > 0) { + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + let leading = StringPrototypeSlice(this.line, 0, this.cursor); + const reversed = ArrayPrototypeJoin( + ArrayPrototypeReverse(ArrayFrom(leading)), + '' + ); + const match = StringPrototypeMatch(reversed, /^\s*(?:[^\w\s]+|\w+)?/); + leading = StringPrototypeSlice( + leading, + 0, + leading.length - match[0].length + ); + this.line = + leading + + StringPrototypeSlice(this.line, this.cursor, this.line.length); + this.cursor = leading.length; + this[kRefreshLine](); + } + } + + [kDeleteWordRight]() { + if (this.cursor < this.line.length) { + const trailing = StringPrototypeSlice(this.line, this.cursor); + const match = StringPrototypeMatch(trailing, /^(?:\s+|\W+|\w+)\s*/); + this.line = + StringPrototypeSlice(this.line, 0, this.cursor) + + StringPrototypeSlice(trailing, match[0].length); + this[kRefreshLine](); + } + } + + [kDeleteLineLeft]() { + this.line = StringPrototypeSlice(this.line, this.cursor); + this.cursor = 0; + this[kRefreshLine](); + } + + [kDeleteLineRight]() { + this.line = StringPrototypeSlice(this.line, 0, this.cursor); + this[kRefreshLine](); + } + + clearLine() { + this[kMoveCursor](+Infinity); + this[kWriteToOutput]('\r\n'); + this.line = ''; + this.cursor = 0; + this.prevRows = 0; + } + + [kLine]() { + const line = this[kAddHistory](); + this.clearLine(); + this[kOnLine](line); + } + + // TODO(BridgeAR): Add underscores to the search part and a red background in + // case no match is found. This should only be the visual part and not the + // actual line content! + // TODO(BridgeAR): In case the substring based search is active and the end is + // reached, show a comment how to search the history as before. E.g., using + // + N. Only show this after two/three UPs or DOWNs, not on the first + // one. + [kHistoryNext]() { + if (this.historyIndex >= 0) { + const search = this[kSubstringSearch] || ''; + let index = this.historyIndex - 1; + while ( + index >= 0 && + (!StringPrototypeStartsWith(this.history[index], search) || + this.line === this.history[index]) + ) { + index--; + } + if (index === -1) { + this.line = search; + } else { + this.line = this.history[index]; + } + this.historyIndex = index; + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); + } + } + + [kHistoryPrev]() { + if (this.historyIndex < this.history.length && this.history.length) { + const search = this[kSubstringSearch] || ''; + let index = this.historyIndex + 1; + while ( + index < this.history.length && + (!StringPrototypeStartsWith(this.history[index], search) || + this.line === this.history[index]) + ) { + index++; + } + if (index === this.history.length) { + this.line = search; + } else { + this.line = this.history[index]; + } + this.historyIndex = index; + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); + } + } + + // Returns the last character's display position of the given string + [kGetDisplayPos](str) { + let offset = 0; + const col = this.columns; + let rows = 0; + str = stripVTControlCharacters(str); + for (const char of new SafeStringIterator(str)) { + if (char === '\n') { + // Rows must be incremented by 1 even if offset = 0 or col = +Infinity. + rows += MathCeil(offset / col) || 1; + offset = 0; + continue; + } + // Tabs must be aligned by an offset of the tab size. + if (char === '\t') { + offset += this.tabSize - (offset % this.tabSize); + continue; + } + const width = getStringWidth(char); + if (width === 0 || width === 1) { + offset += width; + } else { + // width === 2 + if ((offset + 1) % col === 0) { + offset++; + } + offset += 2; + } + } + const cols = offset % col; + rows += (offset - cols) / col; + return { cols, rows }; + } + + /** + * Returns the real position of the cursor in relation + * to the input prompt + string. + * @returns {{ + * rows: number; + * cols: number; + * }} + */ + getCursorPos() { + const strBeforeCursor = + this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor); + return this[kGetDisplayPos](strBeforeCursor); + } + + // This function moves cursor dx places to the right + // (-dx for left) and refreshes the line if it is needed. + [kMoveCursor](dx) { + if (dx === 0) { + return; + } + 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 cursor stayed on the line. + if (oldPos.rows === newPos.rows) { + const diffWidth = newPos.cols - oldPos.cols; + moveCursor(this.output, diffWidth, 0); + } else { + this[kRefreshLine](); + } + } + + // Handle a write from the tty + [kTtyWrite](s, key) { + const previousKey = this[kPreviousKey]; + key = key || {}; + this[kPreviousKey] = key; + + // Activate or deactivate substring search. + if ( + (key.name === 'up' || key.name === 'down') && + !key.ctrl && + !key.meta && + !key.shift + ) { + if (this[kSubstringSearch] === null) { + this[kSubstringSearch] = StringPrototypeSlice( + this.line, + 0, + this.cursor + ); + } + } else if (this[kSubstringSearch] !== null) { + this[kSubstringSearch] = null; + // Reset the index in case there's no match. + if (this.history.length === this.historyIndex) { + this.historyIndex = -1; + } + } + + // 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) { + // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is + // identical to -h. It should have a unique escape sequence. + case 'backspace': + this[kDeleteLineLeft](); + break; + + case 'delete': + this[kDeleteLineRight](); + 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[kDeleteLeft](); + 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[kDeleteRight](); + } + break; + + case 'u': // Delete from current to start of line + this[kDeleteLineLeft](); + break; + + case 'k': // Delete from current to end of line + this[kDeleteLineRight](); + break; + + case 'a': // Go to the start of the line + this[kMoveCursor](-Infinity); + break; + + case 'e': // Go to the end of the line + this[kMoveCursor](+Infinity); + break; + + case 'b': // back one character + this[kMoveCursor](-charLengthLeft(this.line, this.cursor)); + break; + + case 'f': // Forward one character + this[kMoveCursor](+charLengthAt(this.line, this.cursor)); + break; + + case 'l': // Clear the whole screen + cursorTo(this.output, 0, 0); + clearScreenDown(this.output); + this[kRefreshLine](); + break; + + case 'n': // next history item + this[kHistoryNext](); + break; + + case 'p': // Previous history item + this[kHistoryPrev](); + break; + + case 'z': + if (process.platform === 'win32') break; + if (this.listenerCount('SIGTSTP') > 0) { + this.emit('SIGTSTP'); + } else { + process.once('SIGCONT', () => { + // Don't raise events if stream has already been abandoned. + if (!this.paused) { + // Stream must be paused and resumed after SIGCONT to catch + // SIGINT, SIGTSTP, and EOF. + this.pause(); + this.emit('SIGCONT'); + } + // Explicitly re-enable "raw mode" and move the cursor to + // the correct position. + // See https://github.com/joyent/node/issues/3295. + this[kSetRawMode](true); + this[kRefreshLine](); + }); + this[kSetRawMode](false); + process.kill(process.pid, 'SIGTSTP'); + } + break; + + case 'w': // Delete backwards to a word boundary + // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is + // identical to -h. It should have a unique escape sequence. + // Falls through + case 'backspace': + this[kDeleteWordLeft](); + break; + + case 'delete': // Delete forward to a word boundary + this[kDeleteWordRight](); + break; + + case 'left': + this[kWordLeft](); + break; + + case 'right': + this[kWordRight](); + break; + } + } else if (key.meta) { + /* Meta key pressed */ + + switch (key.name) { + case 'b': // backward word + this[kWordLeft](); + break; + + case 'f': // forward word + this[kWordRight](); + break; + + case 'd': // delete forward word + case 'delete': + this[kDeleteWordRight](); + break; + + case 'backspace': // Delete backwards to a word boundary + this[kDeleteWordLeft](); + break; + } + } else { + /* No modifier keys used */ + + // \r bookkeeping is only relevant if a \n comes right after. + if (this[kSawReturnAt] && key.name !== 'enter') this[kSawReturnAt] = 0; + + switch (key.name) { + case 'return': // Carriage return, i.e. \r + this[kSawReturnAt] = DateNow(); + this[kLine](); + break; + + case 'enter': + // When key interval > crlfDelay + if ( + this[kSawReturnAt] === 0 || + DateNow() - this[kSawReturnAt] > this.crlfDelay + ) { + this[kLine](); + } + this[kSawReturnAt] = 0; + break; + + case 'backspace': + this[kDeleteLeft](); + break; + + case 'delete': + this[kDeleteRight](); + break; + + case 'left': + // Obtain the code point to the left + this[kMoveCursor](-charLengthLeft(this.line, this.cursor)); + break; + + case 'right': + this[kMoveCursor](+charLengthAt(this.line, this.cursor)); + break; + + case 'home': + this[kMoveCursor](-Infinity); + break; + + case 'end': + this[kMoveCursor](+Infinity); + break; + + case 'up': + this[kHistoryPrev](); + break; + + case 'down': + this[kHistoryNext](); + break; + + case 'tab': + // If tab completion enabled, do that... + if ( + typeof this.completer === 'function' && + this.isCompletionEnabled + ) { + const lastKeypressWasTab = + previousKey && previousKey.name === 'tab'; + this[kTabComplete](lastKeypressWasTab); + break; + } + // falls through + default: + if (typeof s === 'string' && s) { + const lines = StringPrototypeSplit(s, /\r\n|\n|\r/); + for (let i = 0, len = lines.length; i < len; i++) { + if (i > 0) { + this[kLine](); + } + this[kInsertString](lines[i]); + } + } + } + } + } + + /** + * Creates an `AsyncIterator` object that iterates through + * each line in the input stream as a string. + * @typedef {{ + * [Symbol.asyncIterator]: () => InterfaceAsyncIterator, + * next: () => Promise + * }} InterfaceAsyncIterator + * @returns {InterfaceAsyncIterator} + */ + [SymbolAsyncIterator]() { + 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)) { + // TODO(rexagod): drain to resume flow + this.pause(); + } + }; + const closeListener = () => { + readable.push(null); + }; + const errorListener = (err) => { + readable.destroy(err); + }; + this.on('error', errorListener); + this.on('line', lineListener); + this.on('close', closeListener); + this[kLineObjectStream] = readable; + } + + return this[kLineObjectStream][SymbolAsyncIterator](); + } +} + +module.exports = { + Interface, + InterfaceConstructor, + kAddHistory, + kDecoder, + kDeleteLeft, + kDeleteLineLeft, + kDeleteLineRight, + kDeleteRight, + kDeleteWordLeft, + kDeleteWordRight, + kGetDisplayPos, + kHistoryNext, + kHistoryPrev, + kInsertString, + kLine, + kLine_buffer, + kMoveCursor, + kNormalWrite, + kOldPrompt, + kOnLine, + kPreviousKey, + kPrompt, + kQuestionCallback, + kQuestionCancel, + kRefreshLine, + kSawKeyPress, + kSawReturnAt, + kSetRawMode, + kTabComplete, + kTabCompleter, + kTtyWrite, + kWordLeft, + kWordRight, + kWriteToOutput, +}; diff --git a/lib/readline.js b/lib/readline.js index 1b8b276a6d6db0..0b17039ff1768c 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -19,50 +19,17 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -// Inspiration for this code comes from Salvatore Sanfilippo's linenoise. -// https://github.com/antirez/linenoise -// Reference: -// * https://invisible-island.net/xterm/ctlseqs/ctlseqs.html -// * http://www.3waylabs.com/nw/WWW/products/wizcon/vt220.html - 'use strict'; const { - ArrayFrom, - ArrayPrototypeFilter, - ArrayPrototypeIndexOf, - ArrayPrototypeJoin, - ArrayPrototypeMap, - ArrayPrototypePop, - ArrayPrototypeReverse, - ArrayPrototypeSplice, - ArrayPrototypeUnshift, DateNow, FunctionPrototypeBind, FunctionPrototypeCall, - MathCeil, - MathFloor, - MathMax, - MathMaxApply, - NumberIsFinite, - NumberIsNaN, - ObjectDefineProperty, + ObjectDefineProperties, ObjectSetPrototypeOf, - RegExpPrototypeTest, - StringPrototypeCodePointAt, - StringPrototypeEndsWith, - StringPrototypeMatch, - StringPrototypeRepeat, - StringPrototypeReplace, - StringPrototypeSlice, - StringPrototypeSplit, - StringPrototypeStartsWith, - StringPrototypeTrim, Promise, PromiseReject, - Symbol, - SymbolAsyncIterator, - SafeStringIterator, + StringPrototypeSlice, } = primordials; const { @@ -75,347 +42,80 @@ const emitKeypressEvents = require('internal/readline/emitKeypressEvents'); const { AbortError, - codes } = require('internal/errors'); - -const { - ERR_INVALID_ARG_VALUE, -} = codes; -const { - validateAbortSignal, - validateArray, - validateString, - validateUint32, -} = require('internal/validators'); const { inspect, - getStringWidth, - stripVTControlCharacters, } = require('internal/util/inspect'); -const EventEmitter = require('events'); -const { - charLengthAt, - charLengthLeft, - commonPrefix, - kSubstringSearch, -} = require('internal/readline/utils'); - const { promisify } = require('internal/util'); -const { StringDecoder } = require('string_decoder'); - -// Lazy load Readable for startup performance. -let Readable; - /** * @typedef {import('./stream.js').Readable} Readable * @typedef {import('./stream.js').Writable} Writable */ -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 kQuestionCancel = Symbol('kQuestionCancel'); - -// GNU readline library - keyseq-timeout is 500ms (default) -const ESCAPE_CODE_TIMEOUT = 500; - -/** - * Creates a new `readline.Interface` instance. - * @param {Readable | { - * input: Readable; - * output: Writable; - * completer?: Function; - * terminal?: boolean; - * history?: string[]; - * historySize?: number; - * removeHistoryDuplicates?: boolean; - * prompt?: string; - * crlfDelay?: number; - * escapeCodeTimeout?: number; - * tabSize?: number; - * signal?: AbortSignal; - * }} input - * @param {Writable} [output] - * @param {Function} [completer] - * @param {boolean} [terminal] - * @returns {Interface} - */ -function createInterface(input, output, completer, terminal) { - return new Interface(input, output, completer, terminal); -} - +const { + Interface: _Interface, + InterfaceConstructor, + kAddHistory, + kDecoder, + kDeleteLeft, + kDeleteLineLeft, + kDeleteLineRight, + kDeleteRight, + kDeleteWordLeft, + kDeleteWordRight, + kGetDisplayPos, + kHistoryNext, + kHistoryPrev, + kInsertString, + kLine, + kLine_buffer, + kMoveCursor, + kNormalWrite, + kOldPrompt, + kOnLine, + kPreviousKey, + kPrompt, + kQuestionCallback, + kQuestionCancel, + kRefreshLine, + kSawKeyPress, + kSawReturnAt, + kSetRawMode, + kTabComplete, + kTabCompleter, + kTtyWrite, + kWordLeft, + kWordRight, + kWriteToOutput, +} = require('internal/readline/interface'); function Interface(input, output, completer, terminal) { if (!(this instanceof Interface)) { return new Interface(input, output, completer, terminal); } - this._sawReturnAt = 0; - // TODO(BridgeAR): Document this property. The name is not ideal, so we might - // want to expose an alias and document that instead. - this.isCompletionEnabled = true; - this._sawKeyPress = false; - this._previousKey = null; - this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT; - this.tabSize = 8; - - FunctionPrototypeCall(EventEmitter, this); - let history; - let historySize; - let removeHistoryDuplicates = false; - let crlfDelay; - let prompt = '> '; - let signal; - if (input && input.input) { - // An options object was given - output = input.output; - completer = input.completer; - terminal = input.terminal; - history = input.history; - historySize = input.historySize; - signal = input.signal; - if (input.tabSize !== undefined) { - validateUint32(input.tabSize, 'tabSize', true); - this.tabSize = input.tabSize; - } - removeHistoryDuplicates = input.removeHistoryDuplicates; - if (input.prompt !== undefined) { - prompt = input.prompt; - } - if (input.escapeCodeTimeout !== undefined) { - if (NumberIsFinite(input.escapeCodeTimeout)) { - this.escapeCodeTimeout = input.escapeCodeTimeout; - } else { - throw new ERR_INVALID_ARG_VALUE( - 'input.escapeCodeTimeout', - this.escapeCodeTimeout - ); - } - } - - if (signal) { - validateAbortSignal(signal, 'options.signal'); - } - - crlfDelay = input.crlfDelay; - input = input.input; - } - - if (completer !== undefined && typeof completer !== 'function') { - throw new ERR_INVALID_ARG_VALUE('completer', completer); - } - - if (history === undefined) { - history = []; - } else { - validateArray(history, 'history'); - } - - if (historySize === undefined) { - historySize = kHistorySize; + if (input?.input && + typeof input.completer === 'function' && input.completer.length !== 2) { + const { completer } = input; + input.completer = (v, cb) => cb(null, completer(v)); + } else if (typeof completer === 'function' && completer.length !== 2) { + const realCompleter = completer; + completer = (v, cb) => cb(null, realCompleter(v)); } - if (typeof historySize !== 'number' || - NumberIsNaN(historySize) || - historySize < 0) { - throw new ERR_INVALID_ARG_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.line = ''; - this[kSubstringSearch] = null; - this.output = output; - this.input = input; - this.history = history; - this.historySize = historySize; - this.removeHistoryDuplicates = !!removeHistoryDuplicates; - this.crlfDelay = crlfDelay ? - MathMax(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[kQuestionCancel] = FunctionPrototypeBind(_questionCancel, this); - - this.setPrompt(prompt); - - this.terminal = !!terminal; + FunctionPrototypeCall(InterfaceConstructor, this, + input, output, completer, terminal); if (process.env.TERM === 'dumb') { this._ttyWrite = FunctionPrototypeBind(_ttyWriteDumb, this); } - - function onerror(err) { - self.emit('error', err); - } - - 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 = StringPrototypeCodePointAt(key.sequence, 0); - if (ch >= 0xd800 && ch <= 0xdfff) - self._refreshLine(); - } - } - - function onresize() { - self._refreshLine(); - } - - this[kLineObjectStream] = undefined; - - input.on('error', onerror); - - if (!this.terminal) { - function onSelfCloseWithoutTerminal() { - input.removeListener('data', ondata); - input.removeListener('error', onerror); - 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('error', onerror); - 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); - - this._setRawMode(true); - this.terminal = true; - - // Cursor position on the line. - this.cursor = 0; - - this.historyIndex = -1; - - if (output !== null && output !== undefined) - output.on('resize', onresize); - - self.once('close', onSelfCloseWithTerminal); - } - - if (signal) { - const onAborted = () => self.close(); - if (signal.aborted) { - process.nextTick(onAborted); - } else { - signal.addEventListener('abort', onAborted, { once: true }); - self.once('close', () => signal.removeEventListener('abort', onAborted)); - } - } - - // Current line - this.line = ''; - - input.resume(); } -ObjectSetPrototypeOf(Interface.prototype, EventEmitter.prototype); -ObjectSetPrototypeOf(Interface, EventEmitter); +ObjectSetPrototypeOf(Interface.prototype, _Interface.prototype); +ObjectSetPrototypeOf(Interface, _Interface); -ObjectDefineProperty(Interface.prototype, 'columns', { - configurable: true, - enumerable: true, - get: function() { - if (this.output && this.output.columns) - return this.output.columns; - return Infinity; - } -}); - -/** - * Sets the prompt written to the output. - * @param {string} prompt - * @returns {void} - */ -Interface.prototype.setPrompt = function(prompt) { - this._prompt = prompt; -}; - -/** - * Returns the current prompt used by `rl.prompt()`. - * @returns {string} - */ -Interface.prototype.getPrompt = function() { - return this._prompt; -}; - - -Interface.prototype._setRawMode = function(mode) { - const wasInRawMode = this.input.isRaw; - - if (typeof this.input.setRawMode === 'function') { - this.input.setRawMode(mode); - } - - return wasInRawMode; -}; - -/** - * Writes the configured `prompt` to a new line in `output`. - * @param {boolean} [preserveCursor] - * @returns {void} - */ -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); - } -}; +const superQuestion = _Interface.prototype.question; /** * Displays `query` by writing it to the `output`. @@ -439,17 +139,9 @@ Interface.prototype.question = function(query, options, cb) { } if (typeof cb === 'function') { - if (this._questionCallback) { - this.prompt(); - } else { - this._oldPrompt = this._prompt; - this.setPrompt(query); - this._questionCallback = cb; - this.prompt(); - } + FunctionPrototypeCall(superQuestion, this, query, cb); } }; - Interface.prototype.question[promisify.custom] = function(query, options) { options = typeof options === 'object' && options !== null ? options : {}; @@ -468,223 +160,224 @@ Interface.prototype.question[promisify.custom] = function(query, options) { }); }; -function _questionCancel() { - if (this._questionCallback) { - this._questionCallback = null; - this.setPrompt(this._oldPrompt); - this.clearLine(); - } -} - - -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 _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 (StringPrototypeTrim(this.line).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 = ArrayPrototypeIndexOf(this.history, this.line); - if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1); - } - - ArrayPrototypeUnshift(this.history, this.line); - - // Only store so many - if (this.history.length > this.historySize) ArrayPrototypePop(this.history); - } - - this.historyIndex = -1; - - // The listener could change the history object, possibly - // to remove the last added entry if it is sensitive and should - // not be persisted in the history, like a password - const line = this.history[0]; - - // Emit history event to notify listeners of update - this.emit('history', this.history); - - return line; -}; - - -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; -}; - -/** - * Closes the `readline.Interface` instance. - * @returns {void} - */ -Interface.prototype.close = function() { - if (this.closed) return; - this.pause(); - if (this.terminal) { - this._setRawMode(false); - } - this.closed = true; - this.emit('close'); -}; - -/** - * Pauses the `input` stream. - * @returns {void | Interface} - */ -Interface.prototype.pause = function() { - if (this.paused) return; - this.input.pause(); - this.paused = true; - this.emit('pause'); - return this; -}; - /** - * Resumes the `input` stream if paused. - * @returns {void | Interface} - */ -Interface.prototype.resume = function() { - if (!this.paused) return; - this.input.resume(); - this.paused = false; - this.emit('resume'); - return this; -}; - -/** - * Writes either `data` or a `key` sequence identified by - * `key` to the `output`. - * @param {string} d - * @param {{ - * ctrl?: boolean; - * meta?: boolean; - * shift?: boolean; - * name?: string; - * }} [key] - * @returns {void} + * Creates a new `readline.Interface` instance. + * @param {Readable | { + * input: Readable; + * output: Writable; + * completer?: Function; + * terminal?: boolean; + * history?: string[]; + * historySize?: number; + * removeHistoryDuplicates?: boolean; + * prompt?: string; + * crlfDelay?: number; + * escapeCodeTimeout?: number; + * tabSize?: number; + * signal?: AbortSignal; + * }} input + * @param {Writable} [output] + * @param {Function} [completer] + * @param {boolean} [terminal] + * @returns {Interface} */ -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 && - DateNow() - this._sawReturnAt <= this.crlfDelay) { - string = StringPrototypeReplace(string, /^\n/, ''); - this._sawReturnAt = 0; - } - - // Run test() on the new string chunk, not on the entire line buffer. - const newPartContainsEnding = RegExpPrototypeTest(lineEnding, string); - - if (this._line_buffer) { - string = this._line_buffer + string; - this._line_buffer = null; - } - if (newPartContainsEnding) { - this._sawReturnAt = StringPrototypeEndsWith(string, '\r') ? DateNow() : 0; - - // Got one or more newlines; process into "line" events - const lines = StringPrototypeSplit(string, lineEnding); - // Either '' or (conceivably) the unfinished portion of the next line - string = ArrayPrototypePop(lines); - this._line_buffer = string; - for (let 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) { - const beg = StringPrototypeSlice(this.line, 0, this.cursor); - const end = StringPrototypeSlice(this.line, this.cursor, this.line.length); - this.line = beg + c + end; - this.cursor += c.length; - this._refreshLine(); - } else { - this.line += c; - this.cursor += c.length; +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} - if (this.getCursorPos().cols === 0) { - this._refreshLine(); - } else { - this._writeToOutput(c); - } - } -}; +ObjectDefineProperties(Interface.prototype, { + // Redirect internal prototype methods to the underscore notation for backward + // compatibility. + [kSetRawMode]: { + get() { + return this._setRawMode; + } + }, + [kOnLine]: { + get() { + return this._onLine; + } + }, + [kWriteToOutput]: { + get() { + return this._writeToOutput; + } + }, + [kAddHistory]: { + get() { + return this._addHistory; + } + }, + [kRefreshLine]: { + get() { + return this._refreshLine; + } + }, + [kNormalWrite]: { + get() { + return this._normalWrite; + } + }, + [kInsertString]: { + get() { + return this._insertString; + } + }, + [kTabComplete]: { + get() { + return this._tabComplete; + } + }, + [kWordLeft]: { + get() { + return this._wordLeft; + } + }, + [kWordRight]: { + get() { + return this._wordRight; + } + }, + [kDeleteLeft]: { + get() { + return this._deleteLeft; + } + }, + [kDeleteRight]: { + get() { + return this._deleteRight; + } + }, + [kDeleteWordLeft]: { + get() { + return this._deleteWordLeft; + } + }, + [kDeleteWordRight]: { + get() { + return this._deleteWordRight; + } + }, + [kDeleteLineLeft]: { + get() { + return this._deleteLineLeft; + } + }, + [kDeleteLineRight]: { + get() { + return this._deleteLineRight; + } + }, + [kLine]: { + get() { + return this._line; + } + }, + [kHistoryNext]: { + get() { + return this._historyNext; + } + }, + [kHistoryPrev]: { + get() { + return this._historyPrev; + } + }, + [kGetDisplayPos]: { + get() { + return this._getDisplayPos; + } + }, + [kMoveCursor]: { + get() { + return this._moveCursor; + } + }, + [kTtyWrite]: { + get() { + return this._ttyWrite; + } + }, + + // Defining proxies for the internal instance properties for backward + // compatibility. + _decoder: { + get() { + return this[kDecoder]; + }, + set(value) { + this[kDecoder] = value; + }, + }, + _line_buffer: { + get() { + return this[kLine_buffer]; + }, + set(value) { + this[kLine_buffer] = value; + }, + }, + _oldPrompt: { + get() { + return this[kOldPrompt]; + }, + set(value) { + this[kOldPrompt] = value; + }, + }, + _previousKey: { + get() { + return this[kPreviousKey]; + }, + set(value) { + this[kPreviousKey] = value; + }, + }, + _prompt: { + get() { + return this[kPrompt]; + }, + set(value) { + this[kPrompt] = value; + }, + }, + _questionCallback: { + get() { + return this[kQuestionCallback]; + }, + set(value) { + this[kQuestionCallback] = value; + }, + }, + _sawKeyPress: { + get() { + return this[kSawKeyPress]; + }, + set(value) { + this[kSawKeyPress] = value; + }, + }, + _sawReturnAt: { + get() { + return this[kSawReturnAt]; + }, + set(value) { + this[kSawReturnAt] = value; + }, + }, +}); +// Make internal methods public for backward compatibility. +Interface.prototype._setRawMode = _Interface.prototype[kSetRawMode]; +Interface.prototype._onLine = _Interface.prototype[kOnLine]; +Interface.prototype._writeToOutput = _Interface.prototype[kWriteToOutput]; +Interface.prototype._addHistory = _Interface.prototype[kAddHistory]; +Interface.prototype._refreshLine = _Interface.prototype[kRefreshLine]; +Interface.prototype._normalWrite = _Interface.prototype[kNormalWrite]; +Interface.prototype._insertString = _Interface.prototype[kInsertString]; Interface.prototype._tabComplete = function(lastKeypressWasTab) { + // Overriding parent method because `this.completer` in the legacy + // implementation takes a callback instead of being an async function. this.pause(); const string = StringPrototypeSlice(this.line, 0, this.cursor); this.completer(string, (err, value) => { @@ -695,304 +388,32 @@ Interface.prototype._tabComplete = function(lastKeypressWasTab) { return; } - // Result and the text that was completed. - const { 0: completions, 1: completeOn } = value; - - if (!completions || completions.length === 0) { - return; - } - - // If there is a common prefix to all matches, then apply that portion. - const prefix = commonPrefix(ArrayPrototypeFilter(completions, - (e) => e !== '')); - if (StringPrototypeStartsWith(prefix, completeOn) && - prefix.length > completeOn.length) { - this._insertString(StringPrototypeSlice(prefix, completeOn.length)); - return; - } else if (!StringPrototypeStartsWith(completeOn, prefix)) { - this.line = StringPrototypeSlice(this.line, - 0, - this.cursor - completeOn.length) + - prefix + - StringPrototypeSlice(this.line, - this.cursor, - this.line.length); - this.cursor = this.cursor - completeOn.length + prefix.length; - this._refreshLine(); - return; - } - - if (!lastKeypressWasTab) { - return; - } - - // Apply/show completions. - const completionsWidth = ArrayPrototypeMap(completions, - (e) => getStringWidth(e)); - const width = MathMaxApply(completionsWidth) + 2; // 2 space padding - let maxColumns = MathFloor(this.columns / width) || 1; - if (maxColumns === Infinity) { - maxColumns = 1; - } - let output = '\r\n'; - let lineIndex = 0; - let whitespace = 0; - for (let i = 0; i < completions.length; i++) { - const completion = completions[i]; - if (completion === '' || lineIndex === maxColumns) { - output += '\r\n'; - lineIndex = 0; - whitespace = 0; - } else { - output += StringPrototypeRepeat(' ', whitespace); - } - if (completion !== '') { - output += completion; - whitespace = width - completionsWidth[i]; - lineIndex++; - } else { - output += '\r\n'; - } - } - if (lineIndex !== 0) { - output += '\r\n\r\n'; - } - this._writeToOutput(output); - this._refreshLine(); + this[kTabCompleter](lastKeypressWasTab, value); }); }; - -Interface.prototype._wordLeft = function() { - if (this.cursor > 0) { - // Reverse the string and match a word near beginning - // to avoid quadratic time complexity - const leading = StringPrototypeSlice(this.line, 0, this.cursor); - const reversed = ArrayPrototypeJoin( - ArrayPrototypeReverse(ArrayFrom(leading)), ''); - const match = StringPrototypeMatch(reversed, /^\s*(?:[^\w\s]+|\w+)?/); - this._moveCursor(-match[0].length); - } -}; - - -Interface.prototype._wordRight = function() { - if (this.cursor < this.line.length) { - const trailing = StringPrototypeSlice(this.line, this.cursor); - const match = StringPrototypeMatch(trailing, /^(?:\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 = StringPrototypeSlice(this.line, 0, this.cursor - charSize) + - StringPrototypeSlice(this.line, 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 = StringPrototypeSlice(this.line, 0, this.cursor) + - StringPrototypeSlice(this.line, 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 - let leading = StringPrototypeSlice(this.line, 0, this.cursor); - const reversed = ArrayPrototypeJoin( - ArrayPrototypeReverse(ArrayFrom(leading)), ''); - const match = StringPrototypeMatch(reversed, /^\s*(?:[^\w\s]+|\w+)?/); - leading = StringPrototypeSlice(leading, 0, - leading.length - match[0].length); - this.line = leading + StringPrototypeSlice(this.line, this.cursor, - this.line.length); - this.cursor = leading.length; - this._refreshLine(); - } -}; - - -Interface.prototype._deleteWordRight = function() { - if (this.cursor < this.line.length) { - const trailing = StringPrototypeSlice(this.line, this.cursor); - const match = StringPrototypeMatch(trailing, /^(?:\s+|\W+|\w+)\s*/); - this.line = StringPrototypeSlice(this.line, 0, this.cursor) + - StringPrototypeSlice(trailing, match[0].length); - this._refreshLine(); - } -}; - - -Interface.prototype._deleteLineLeft = function() { - this.line = StringPrototypeSlice(this.line, this.cursor); - this.cursor = 0; - this._refreshLine(); -}; - - -Interface.prototype._deleteLineRight = function() { - this.line = StringPrototypeSlice(this.line, 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); -}; - -// TODO(BridgeAR): Add underscores to the search part and a red background in -// case no match is found. This should only be the visual part and not the -// actual line content! -// TODO(BridgeAR): In case the substring based search is active and the end is -// reached, show a comment how to search the history as before. E.g., using -// + N. Only show this after two/three UPs or DOWNs, not on the first -// one. -Interface.prototype._historyNext = function() { - if (this.historyIndex >= 0) { - const search = this[kSubstringSearch] || ''; - let index = this.historyIndex - 1; - while (index >= 0 && - (!StringPrototypeStartsWith(this.history[index], search) || - this.line === this.history[index])) { - index--; - } - if (index === -1) { - this.line = search; - } else { - this.line = this.history[index]; - } - this.historyIndex = index; - this.cursor = this.line.length; // Set cursor to end of line. - this._refreshLine(); - } -}; - -Interface.prototype._historyPrev = function() { - if (this.historyIndex < this.history.length && this.history.length) { - const search = this[kSubstringSearch] || ''; - let index = this.historyIndex + 1; - while (index < this.history.length && - (!StringPrototypeStartsWith(this.history[index], search) || - this.line === this.history[index])) { - index++; - } - if (index === this.history.length) { - this.line = search; - } else { - this.line = this.history[index]; - } - this.historyIndex = index; - 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 rows = 0; - str = stripVTControlCharacters(str); - for (const char of new SafeStringIterator(str)) { - if (char === '\n') { - // Rows must be incremented by 1 even if offset = 0 or col = +Infinity. - rows += MathCeil(offset / col) || 1; - offset = 0; - continue; - } - // Tabs must be aligned by an offset of the tab size. - if (char === '\t') { - offset += this.tabSize - (offset % this.tabSize); - continue; - } - const width = getStringWidth(char); - if (width === 0 || width === 1) { - offset += width; - } else { // width === 2 - if ((offset + 1) % col === 0) { - offset++; - } - offset += 2; - } - } - const cols = offset % col; - rows += (offset - cols) / col; - return { cols, rows }; -}; - -/** - * Returns the real position of the cursor in relation - * to the input prompt + string. - * @returns {{ - * rows: number; - * cols: number; - * }} - */ -Interface.prototype.getCursorPos = function() { - const strBeforeCursor = this._prompt + - StringPrototypeSlice(this.line, 0, this.cursor); - return this._getDisplayPos(strBeforeCursor); -}; -Interface.prototype._getCursorPos = Interface.prototype.getCursorPos; - -// 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) { - if (dx === 0) { - return; - } - 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 cursor stayed on the line. - if (oldPos.rows === newPos.rows) { - const diffWidth = newPos.cols - oldPos.cols; - moveCursor(this.output, diffWidth, 0); - } else { - this._refreshLine(); - } -}; +Interface.prototype._wordLeft = _Interface.prototype[kWordLeft]; +Interface.prototype._wordRight = _Interface.prototype[kWordRight]; +Interface.prototype._deleteLeft = _Interface.prototype[kDeleteLeft]; +Interface.prototype._deleteRight = _Interface.prototype[kDeleteRight]; +Interface.prototype._deleteWordLeft = _Interface.prototype[kDeleteWordLeft]; +Interface.prototype._deleteWordRight = _Interface.prototype[kDeleteWordRight]; +Interface.prototype._deleteLineLeft = _Interface.prototype[kDeleteLineLeft]; +Interface.prototype._deleteLineRight = _Interface.prototype[kDeleteLineRight]; +Interface.prototype._line = _Interface.prototype[kLine]; +Interface.prototype._historyNext = _Interface.prototype[kHistoryNext]; +Interface.prototype._historyPrev = _Interface.prototype[kHistoryPrev]; +Interface.prototype._getDisplayPos = _Interface.prototype[kGetDisplayPos]; +Interface.prototype._getCursorPos = _Interface.prototype.getCursorPos; +Interface.prototype._moveCursor = _Interface.prototype[kMoveCursor]; +Interface.prototype._ttyWrite = _Interface.prototype[kTtyWrite]; function _ttyWriteDumb(s, key) { key = key || {}; if (key.name === 'escape') return; - if (this._sawReturnAt && key.name !== 'enter') - this._sawReturnAt = 0; + if (this[kSawReturnAt] && key.name !== 'enter') + this[kSawReturnAt] = 0; if (key.ctrl) { if (key.name === 'c') { @@ -1012,17 +433,17 @@ function _ttyWriteDumb(s, key) { switch (key.name) { case 'return': // Carriage return, i.e. \r - this._sawReturnAt = DateNow(); + this[kSawReturnAt] = DateNow(); this._line(); break; case 'enter': // When key interval > crlfDelay - if (this._sawReturnAt === 0 || - DateNow() - this._sawReturnAt > this.crlfDelay) { + if (this[kSawReturnAt] === 0 || + DateNow() - this[kSawReturnAt] > this.crlfDelay) { this._line(); } - this._sawReturnAt = 0; + this[kSawReturnAt] = 0; break; default: @@ -1034,295 +455,6 @@ function _ttyWriteDumb(s, key) { } } -// Handle a write from the tty -Interface.prototype._ttyWrite = function(s, key) { - const previousKey = this._previousKey; - key = key || {}; - this._previousKey = key; - - // Activate or deactivate substring search. - if ((key.name === 'up' || key.name === 'down') && - !key.ctrl && !key.meta && !key.shift) { - if (this[kSubstringSearch] === null) { - this[kSubstringSearch] = StringPrototypeSlice(this.line, 0, this.cursor); - } - } else if (this[kSubstringSearch] !== null) { - this[kSubstringSearch] = null; - // Reset the index in case there's no match. - if (this.history.length === this.historyIndex) { - this.historyIndex = -1; - } - } - - // 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) { - // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is - // identical to -h. It should have a unique escape sequence. - 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', () => { - // Don't raise events if stream has already been abandoned. - if (!this.paused) { - // Stream must be paused and resumed after SIGCONT to catch - // SIGINT, SIGTSTP, and EOF. - this.pause(); - this.emit('SIGCONT'); - } - // Explicitly re-enable "raw mode" and move the cursor to - // the correct position. - // See https://github.com/joyent/node/issues/3295. - this._setRawMode(true); - this._refreshLine(); - }); - this._setRawMode(false); - process.kill(process.pid, 'SIGTSTP'); - } - break; - - case 'w': // Delete backwards to a word boundary - // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is - // identical to -h. It should have a unique escape sequence. - // Falls through - 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 = DateNow(); - this._line(); - break; - - case 'enter': - // When key interval > crlfDelay - if (this._sawReturnAt === 0 || - DateNow() - 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) { - const lines = StringPrototypeSplit(s, /\r\n|\n|\r/); - for (let i = 0, len = lines.length; i < len; i++) { - if (i > 0) { - this._line(); - } - this._insertString(lines[i]); - } - } - } - } -}; - -/** - * Creates an `AsyncIterator` object that iterates through - * each line in the input stream as a string. - * @returns {Symbol.AsyncIterator} - */ -Interface.prototype[SymbolAsyncIterator] = 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)) { - // TODO(rexagod): drain to resume flow - this.pause(); - } - }; - const closeListener = () => { - readable.push(null); - }; - const errorListener = (err) => { - readable.destroy(err); - }; - this.on('error', errorListener); - this.on('line', lineListener); - this.on('close', closeListener); - this[kLineObjectStream] = readable; - } - - return this[kLineObjectStream][SymbolAsyncIterator](); -}; - module.exports = { Interface, clearLine, From 8122d243ae010f3a5c1d50e4d0ef6374d4e407b4 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 27 Apr 2021 16:14:39 +0200 Subject: [PATCH 2/4] readline: introduce promise-based API PR-URL: https://github.com/nodejs/node/pull/37947 Fixes: https://github.com/nodejs/node/issues/37287 Reviewed-By: Matteo Collina Reviewed-By: Benjamin Gruenbaum Reviewed-By: Robert Nagy --- doc/api/readline.md | 467 ++++++- lib/internal/readline/promises.js | 131 ++ lib/readline.js | 4 +- lib/readline/promises.js | 51 + test/parallel/test-readline-promises-csi.mjs | 163 +++ .../test-readline-promises-interface.js | 1076 +++++++++++++++++ .../test-readline-promises-tab-complete.js | 116 ++ tools/doc/type-parser.mjs | 7 +- 8 files changed, 1963 insertions(+), 52 deletions(-) create mode 100644 lib/internal/readline/promises.js create mode 100644 lib/readline/promises.js create mode 100644 test/parallel/test-readline-promises-csi.mjs create mode 100644 test/parallel/test-readline-promises-interface.js create mode 100644 test/parallel/test-readline-promises-tab-complete.js diff --git a/doc/api/readline.md b/doc/api/readline.md index 8f056c942e31b7..b886c5b2bb6578 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -7,22 +7,48 @@ The `readline` module provides an interface for reading data from a [Readable][] -stream (such as [`process.stdin`][]) one line at a time. It can be accessed -using: +stream (such as [`process.stdin`][]) one line at a time. -```js +To use the promise-based APIs: + +```mjs +import * as readline from 'node:readline/promises'; +``` + +```cjs +const readline = require('readline/promises'); +``` + +To use the callback and sync APIs: + +```mjs +import * as readline from 'node:readline'; +``` + +```cjs const readline = require('readline'); ``` The following simple example illustrates the basic use of the `readline` module. -```js +```mjs +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'process'; + +const rl = readline.createInterface({ input, output }); + +const answer = await rl.question('What do you think of Node.js? '); + +console.log(`Thank you for your valuable feedback: ${answer}`); + +rl.close(); +``` + +```cjs const readline = require('readline'); +const { stdin: input, stdout: output } = require('process'); -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); +const rl = readline.createInterface({ input, output }); rl.question('What do you think of Node.js? ', (answer) => { // TODO: Log the answer in a database @@ -36,16 +62,18 @@ Once this code is invoked, the Node.js application will not terminate until the `readline.Interface` is closed because the interface waits for data to be received on the `input` stream. -## Class: `Interface` + +## Class: `InterfaceConstructor` * Extends: {EventEmitter} -Instances of the `readline.Interface` class are constructed using the -`readline.createInterface()` method. Every instance is associated with a -single `input` [Readable][] stream and a single `output` [Writable][] stream. +Instances of the `InterfaceConstructor` class are constructed using the +`readlinePromises.createInterface()` or `readline.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. @@ -56,18 +84,18 @@ added: v0.1.98 The `'close'` event is emitted when one of the following occur: -* The `rl.close()` method is called and the `readline.Interface` instance has +* The `rl.close()` method is called and the `InterfaceConstructor` instance has relinquished control over the `input` and `output` streams; * The `input` stream receives its `'end'` event; * The `input` stream receives Ctrl+D to signal end-of-transmission (EOT); * The `input` stream receives Ctrl+C to signal `SIGINT` and there is no `'SIGINT'` event listener registered on the - `readline.Interface` instance. + `InterfaceConstructor` instance. The listener function is called without passing any arguments. -The `readline.Interface` instance is finished once the `'close'` event is +The `InterfaceConstructor` instance is finished once the `'close'` event is emitted. ### Event: `'line'` @@ -220,12 +248,12 @@ The `'SIGTSTP'` event is _not_ supported on Windows. added: v0.1.98 --> -The `rl.close()` method closes the `readline.Interface` instance and +The `rl.close()` method closes the `InterfaceConstructor` 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.Interface` instance. +from being emitted by the `InterfaceConstructor` instance. ### `rl.pause()` + +### Class: `readlinePromises.Interface` + + +* Extends: {readline.InterfaceConstructor} + +Instances of the `readlinePromises.Interface` class are constructed using the +`readlinePromises.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. + +#### `rl.question(query[, options])` + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* `options` {Object} + * `signal` {AbortSignal} Optionally allows the `question()` to be canceled + using an `AbortController`. +* Returns: {Promise} A promise that is fulfilled with the user's + input in response to the `query`. + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then invokes the `callback` +function passing the provided input as the first argument. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readlinePromises.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +Example usage: + +```mjs +const answer = await rl.question('What is your favorite food? '); +console.log(`Oh, so your favorite food is ${answer}`); +``` + +Using an `AbortController` to cancel a question. + +```mjs +const ac = new AbortController(); +const signal = ac.signal; + +const answer = await rl.question('What is your favorite food? ', { signal }); +console.log(`Oh, so your favorite food is ${answer}`); + +signal.addEventListener('abort', () => { + console.log('The food question timed out'); +}, { once: true }); + +setTimeout(() => ac.abort(), 10000); +``` + +### Class: `readlinePromises.Readline` + + +#### `new readlinePromises.Readline(stream)` + + +* `stream` {stream.Writable} A [TTY][] stream. + +#### `rl.clearLine(dir)` + + +* `dir` {integer} + * `-1`: to the left from cursor + * `1`: to the right from cursor + * `0`: the entire line +* Returns: this + +The `rl.clearLine()` method adds to the internal list of pending action an +action that clears current line of the associated `stream` in a specified +direction identified by `dir`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.clearScreenDown()` + + +* Returns: this + +The `rl.clearScreenDown()` method adds to the internal list of pending action an +action that clears the associated stream from the current position of the +cursor down. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.commit()` + + +* Returns: {Promise} + +The `rl.commit()` method sends all the pending actions to the associated +`stream` and clears the internal list of pending actions. + +#### `rl.cursorTo(x[, y])` + + +* `x` {integer} +* `y` {integer} +* Returns: this + +The `rl.cursorTo()` method adds to the internal list of pending action an action +that moves cursor to the specified position in the associated `stream`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.moveCursor(dx, dy)` + + +* `dx` {integer} +* `dy` {integer} +* Returns: this + +The `rl.moveCursor()` method adds to the internal list of pending action an +action that moves the cursor *relative* to its current position in the +associated `stream`. +You need to call `rl.commit()` to see the effect of this method. + +#### `rl.rollback()` + + +* Returns: this + +The `rl.rollback` methods clears the internal list of pending actions without +sending it to the associated `stream`. + +### `readlinePromises.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. + * `history` {string[]} Initial list of history lines. 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:** `[]`. + * `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`. + * `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`. + * `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`. + * `escapeCodeTimeout` {number} The duration `readlinePromises` 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`. + * `tabSize` {integer} The number of spaces a tab is equal to (minimum 1). + **Default:** `8`. +* Returns: {readlinePromises.Interface} + +The `readlinePromises.createInterface()` method creates a new `readlinePromises.Interface` +instance. + +```js +const readlinePromises = require('readline/promises'); +const rl = readlinePromises.createInterface({ + input: process.stdin, + output: process.stdout +}); +``` + +Once the `readlinePromises.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 an `Array` with 2 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) { + const completions = '.help .error .exit .quit .q'.split(' '); + const hits = completions.filter((c) => c.startsWith(line)); + // Show all completions if none found + return [hits.length ? hits : completions, line]; +} +``` + +The `completer` function can also returns a {Promise}, or be asynchronous: + +```js +async function completer(linePartial) { + await someAsyncWork(); + return [['123'], linePartial]; +} +``` + +## Callback API + + +### Class: `readline.Interface` + + +* Extends: {readline.InterfaceConstructor} + +Instances of the `readline.Interface` class are constructed using the +`readline.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. + +#### `rl.question(query[, options], callback)` + + +* `query` {string} A statement or query to write to `output`, prepended to the + prompt. +* `options` {Object} + * `signal` {AbortSignal} Optionally allows the `question()` to be canceled + using an `AbortController`. +* `callback` {Function} A callback function that is invoked with the user's + input in response to the `query`. + +The `rl.question()` method displays the `query` by writing it to the `output`, +waits for user input to be provided on `input`, then invokes the `callback` +function passing the provided input as the first argument. + +When called, `rl.question()` will resume the `input` stream if it has been +paused. + +If the `readline.Interface` was created with `output` set to `null` or +`undefined` the `query` is not written. + +The `callback` function passed to `rl.question()` does not follow the typical +pattern of accepting an `Error` object or `null` as the first argument. +The `callback` is called with the provided answer as the only argument. + +Example usage: + +```js +rl.question('What is your favorite food? ', (answer) => { + console.log(`Oh, so your favorite food is ${answer}`); +}); +``` + +Using an `AbortController` to cancel a question. + +```js +const ac = new AbortController(); +const signal = ac.signal; + +rl.question('What is your favorite food? ', { signal }, (answer) => { + console.log(`Oh, so your favorite food is ${answer}`); +}); + +signal.addEventListener('abort', () => { + console.log('The food question timed out'); +}, { once: true }); + +setTimeout(() => ac.abort(), 10000); +``` + +If this method is invoked as it's util.promisify()ed version, it returns a +Promise that fulfills with the answer. If the question is canceled using +an `AbortController` it will reject with an `AbortError`. + +```js +const util = require('util'); +const question = util.promisify(rl.question).bind(rl); + +async function questionExample() { + try { + const answer = await question('What is you favorite food? '); + console.log(`Oh, so your favorite food is ${answer}`); + } catch (err) { + console.error('Question rejected', err); + } +} +questionExample(); +``` + +### `readline.clearLine(stream, dir[, callback])` + +* `stream` {stream.Writable} +* `dx` {number} +* `dy` {number} +* `callback` {Function} Invoked once the operation completes. +* Returns: {boolean} `false` if `stream` wishes for the calling code to wait for + the `'drain'` event to be emitted before continuing to write additional data; + otherwise `true`. + +The `readline.moveCursor()` method moves the cursor *relative* to its current +position in a given [TTY][] `stream`. + ## `readline.emitKeypressEvents(stream[, interface])` * `stream` {stream.Readable} -* `interface` {readline.Interface} +* `interface` {readline.InterfaceConstructor} The `readline.emitKeypressEvents()` method causes the given [Readable][] stream to begin emitting `'keypress'` events corresponding to received input. @@ -720,26 +1107,6 @@ if (process.stdin.isTTY) process.stdin.setRawMode(true); ``` -## `readline.moveCursor(stream, dx, dy[, callback])` - - -* `stream` {stream.Writable} -* `dx` {number} -* `dy` {number} -* `callback` {Function} Invoked once the operation completes. -* Returns: {boolean} `false` if `stream` wishes for the calling code to wait for - the `'drain'` event to be emitted before continuing to write additional data; - otherwise `true`. - -The `readline.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/promises.js b/lib/internal/readline/promises.js new file mode 100644 index 00000000000000..3e20085b281dc2 --- /dev/null +++ b/lib/internal/readline/promises.js @@ -0,0 +1,131 @@ +'use strict'; + +const { + ArrayPrototypeJoin, + ArrayPrototypePush, + Promise, +} = primordials; + +const { CSI } = require('internal/readline/utils'); +const { validateInteger } = require('internal/validators'); +const { isWritable } = require('internal/streams/utils'); +const { codes: { ERR_INVALID_ARG_TYPE } } = require('internal/errors'); + +const { + kClearToLineBeginning, + kClearToLineEnd, + kClearLine, + kClearScreenDown, +} = CSI; + +class Readline { + #stream; + #todo = []; + + constructor(stream) { + if (!isWritable(stream)) + throw new ERR_INVALID_ARG_TYPE('stream', 'Writable', stream); + this.#stream = stream; + } + + /** + * Moves the cursor to the x and y coordinate on the given stream. + * @param {integer} x + * @param {integer} [y] + * @returns {Readline} this + */ + cursorTo(x, y = undefined) { + validateInteger(x, 'x'); + if (y != null) validateInteger(y, 'y'); + + ArrayPrototypePush( + this.#todo, + y == null ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H` + ); + + return this; + } + + /** + * Moves the cursor relative to its current location. + * @param {integer} dx + * @param {integer} dy + * @returns {Readline} this + */ + moveCursor(dx, dy) { + if (dx || dy) { + validateInteger(dx, 'dx'); + validateInteger(dy, 'dy'); + + let data = ''; + + if (dx < 0) { + data += CSI`${-dx}D`; + } else if (dx > 0) { + data += CSI`${dx}C`; + } + + if (dy < 0) { + data += CSI`${-dy}A`; + } else if (dy > 0) { + data += CSI`${dy}B`; + } + ArrayPrototypePush(this.#todo, data); + } + return this; + } + + /** + * Clears the current line the cursor is on. + * @param {-1|0|1} dir Direction to clear: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + * @returns {Readline} this + */ + clearLine(dir) { + validateInteger(dir, 'dir', -1, 1); + + ArrayPrototypePush( + this.#todo, + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine + ); + return this; + } + + /** + * Clears the screen from the current position of the cursor down. + * @returns {Readline} this + */ + clearScreenDown() { + ArrayPrototypePush(this.#todo, kClearScreenDown); + return this; + } + + /** + * Sends all the pending actions to the associated `stream` and clears the + * internal list of pending actions. + * @returns {Promise} Resolves when all pending actions have been + * flushed to the associated `stream`. + */ + commit() { + return new Promise((resolve) => { + this.#stream.write(ArrayPrototypeJoin(this.#todo, ''), resolve); + this.#todo = []; + }); + } + + /** + * Clears the internal list of pending actions without sending it to the + * associated `stream`. + * @returns {Readline} this + */ + rollback() { + this.#todo = []; + return this; + } +} + +module.exports = { + Readline, +}; diff --git a/lib/readline.js b/lib/readline.js index 0b17039ff1768c..1d9e839f2c2a17 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -39,6 +39,7 @@ const { moveCursor, } = require('internal/readline/callbacks'); const emitKeypressEvents = require('internal/readline/emitKeypressEvents'); +const promises = require('readline/promises'); const { AbortError, @@ -462,5 +463,6 @@ module.exports = { createInterface, cursorTo, emitKeypressEvents, - moveCursor + moveCursor, + promises, }; diff --git a/lib/readline/promises.js b/lib/readline/promises.js new file mode 100644 index 00000000000000..90658e5a5e9c41 --- /dev/null +++ b/lib/readline/promises.js @@ -0,0 +1,51 @@ +'use strict'; + +const { + Promise, +} = primordials; + +const { + Readline, +} = require('internal/readline/promises'); + +const { + Interface: _Interface, + kQuestionCancel, +} = require('internal/readline/interface'); + +const { + AbortError, +} = require('internal/errors'); + +class Interface extends _Interface { + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + question(query, options = {}) { + return new Promise((resolve, reject) => { + if (options.signal) { + if (options.signal.aborted) { + return reject(new AbortError()); + } + + options.signal.addEventListener('abort', () => { + this[kQuestionCancel](); + reject(new AbortError()); + }, { once: true }); + } + + super.question(query, resolve); + }); + } +} + +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + +module.exports = { + Interface, + Readline, + createInterface, +}; diff --git a/test/parallel/test-readline-promises-csi.mjs b/test/parallel/test-readline-promises-csi.mjs new file mode 100644 index 00000000000000..1ba105fc198d95 --- /dev/null +++ b/test/parallel/test-readline-promises-csi.mjs @@ -0,0 +1,163 @@ +// Flags: --expose-internals + + +import '../common/index.mjs'; +import assert from 'assert'; +import { Readline } from 'readline/promises'; +import { Writable } from 'stream'; + +import utils from 'internal/readline/utils'; +const { CSI } = utils; + +const INVALID_ARG = { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', +}; + +class TestWritable extends Writable { + data = ''; + _write(chunk, encoding, callback) { + this.data += chunk.toString(); + callback(); + } +} + +[ + undefined, null, + 0, 1, 1n, 1.1, NaN, Infinity, + true, false, + Symbol(), + '', '1', + [], {}, () => {}, +].forEach((arg) => + assert.throws(() => new Readline(arg), INVALID_ARG) +); + +{ + const writable = new TestWritable(); + const readline = new Readline(writable); + + await readline.clearScreenDown().commit(); + assert.deepStrictEqual(writable.data, CSI.kClearScreenDown); + await readline.clearScreenDown().commit(); + + writable.data = ''; + await readline.clearScreenDown().rollback(); + assert.deepStrictEqual(writable.data, ''); + + writable.data = ''; + await readline.clearLine(-1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + + writable.data = ''; + await readline.clearLine(1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineEnd); + + writable.data = ''; + await readline.clearLine(0).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearLine); + + writable.data = ''; + await readline.clearLine(-1).commit(); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + + await readline.clearLine(0, null).commit(); + + // Nothing is written when moveCursor 0, 0 + for (const set of + [ + [0, 0, ''], + [1, 0, '\x1b[1C'], + [-1, 0, '\x1b[1D'], + [0, 1, '\x1b[1B'], + [0, -1, '\x1b[1A'], + [1, 1, '\x1b[1C\x1b[1B'], + [-1, 1, '\x1b[1D\x1b[1B'], + [-1, -1, '\x1b[1D\x1b[1A'], + [1, -1, '\x1b[1C\x1b[1A'], + ]) { + writable.data = ''; + await readline.moveCursor(set[0], set[1]).commit(); + assert.deepStrictEqual(writable.data, set[2]); + writable.data = ''; + await readline.moveCursor(set[0], set[1]).commit(); + assert.deepStrictEqual(writable.data, set[2]); + } + + + await readline.moveCursor(1, 1, null).commit(); + + writable.data = ''; + [ + undefined, null, + true, false, + Symbol(), + '', '1', + [], {}, () => {}, + ].forEach((arg) => + assert.throws(() => readline.cursorTo(arg), INVALID_ARG) + ); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo('a', 'b'), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo('a', 1), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + assert.throws(() => readline.cursorTo(1, 'a'), INVALID_ARG); + assert.strictEqual(writable.data, ''); + + writable.data = ''; + await readline.cursorTo(1).commit(); + assert.strictEqual(writable.data, '\x1b[2G'); + + writable.data = ''; + await readline.cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1).cursorTo(1, 2).commit(); + assert.strictEqual(writable.data, '\x1b[2G\x1b[3;2H'); + + writable.data = ''; + await readline.cursorTo(1).commit(); + assert.strictEqual(writable.data, '\x1b[2G'); + + // Verify that cursorTo() rejects if x or y is NaN. + [1.1, NaN, Infinity].forEach((arg) => { + assert.throws(() => readline.cursorTo(arg), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); + }); + + [1.1, NaN, Infinity].forEach((arg) => { + assert.throws(() => readline.cursorTo(1, arg), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); + }); + + assert.throws(() => readline.cursorTo(NaN, NaN), { + code: 'ERR_OUT_OF_RANGE', + name: 'RangeError', + }); +} + +{ + const error = new Error(); + const writable = new class extends Writable { + _write() { throw error; } + }(); + const readline = new Readline(writable); + + await assert.rejects(readline.cursorTo(1).commit(), error); +} diff --git a/test/parallel/test-readline-promises-interface.js b/test/parallel/test-readline-promises-interface.js new file mode 100644 index 00000000000000..79803f99c19450 --- /dev/null +++ b/test/parallel/test-readline-promises-interface.js @@ -0,0 +1,1076 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +common.skipIfDumbTerminal(); + +const assert = require('assert'); +const readline = require('readline/promises'); +const { + getStringWidth, + stripVTControlCharacters +} = require('internal/util/inspect'); +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; +} + +function getInterface(options) { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + ...options, + }); + return [rli, fi]; +} + +function assertCursorRowsAndCols(rli, rows, cols) { + const cursorPos = rli.getCursorPos(); + assert.strictEqual(cursorPos.rows, rows); + assert.strictEqual(cursorPos.cols, cols); +} + +[ + undefined, + 50, + 0, + 100.5, + 5000, +].forEach((crlfDelay) => { + const [rli] = getInterface({ crlfDelay }); + assert.strictEqual(rli.crlfDelay, Math.max(crlfDelay || 100, 100)); + rli.close(); +}); + +{ + const input = new FakeInput(); + + // Constructor throws if completer is not a function or undefined + assert.throws(() => { + readline.createInterface({ + input, + completer: 'string is not valid' + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + assert.throws(() => { + readline.createInterface({ + input, + completer: '' + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + assert.throws(() => { + readline.createInterface({ + input, + completer: false + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + + // Constructor throws if history is not an array + ['not an array', 123, 123n, {}, true, Symbol(), null].forEach((history) => { + assert.throws(() => { + readline.createInterface({ + input, + history, + }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + // Constructor throws if historySize is not a positive number + ['not a number', -1, NaN, {}, true, Symbol(), null].forEach((historySize) => { + assert.throws(() => { + readline.createInterface({ + input, + historySize, + }); + }, { + name: 'RangeError', + code: 'ERR_INVALID_ARG_VALUE' + }); + }); + + // Check for invalid tab sizes. + assert.throws( + () => new readline.Interface({ + input, + tabSize: 0 + }), + { + message: 'The value of "tabSize" is out of range. ' + + 'It must be >= 1 && < 4294967296. Received 0', + code: 'ERR_OUT_OF_RANGE' + } + ); + + assert.throws( + () => new readline.Interface({ + input, + tabSize: '4' + }), + { code: 'ERR_INVALID_ARG_TYPE' } + ); + + assert.throws( + () => new readline.Interface({ + input, + tabSize: 4.5 + }), + { + code: 'ERR_OUT_OF_RANGE', + message: 'The value of "tabSize" is out of range. ' + + 'It must be an integer. Received 4.5' + } + ); +} + +// Sending a single character with no newline +{ + const fi = new FakeInput(); + const rli = new readline.Interface(fi, {}); + rli.on('line', common.mustNotCall()); + fi.emit('data', 'a'); + 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 [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\r')); + rli.close(); +} + +// \r at start of input should output blank line +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['', 'foo' ]; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length)); + fi.emit('data', '\rfoo\r'); + rli.close(); +} + +// \t does not become part of the input when there is a completer function +{ + const completer = (line) => [[], line]; + const [rli, fi] = getInterface({ terminal: true, completer }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'foo'); + })); + for (const character of '\tfo\to\t') { + fi.emit('data', character); + } + fi.emit('data', '\n'); + rli.close(); +} + +// \t when there is no completer function should behave like an ordinary +// character +{ + const [rli, fi] = getInterface({ terminal: true }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '\t'); + })); + fi.emit('data', '\t'); + fi.emit('data', '\n'); + rli.close(); +} + +// Adding history lines should emit the history event with +// the history array +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('history', common.mustCall((history) => { + const expectedHistory = expectedLines.slice(0, history.length).reverse(); + assert.deepStrictEqual(history, expectedHistory); + }, expectedLines.length)); + for (const line of expectedLines) { + fi.emit('data', `${line}\n`); + } + rli.close(); +} + +// Altering the history array in the listener should not alter +// the line being processed +{ + const [rli, fi] = getInterface({ terminal: true }); + const expectedLine = 'foo'; + rli.on('history', common.mustCall((history) => { + assert.strictEqual(history[0], expectedLine); + history.shift(); + })); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLine); + assert.strictEqual(rli.history.length, 0); + })); + fi.emit('data', `${expectedLine}\n`); + rli.close(); +} + +// Duplicate lines are removed from history when +// `options.removeHistoryDuplicates` is `true` +{ + const [rli, fi] = getInterface({ + terminal: true, + removeHistoryDuplicates: true + }); + const expectedLines = ['foo', 'bar', 'baz', 'bar', 'bat', 'bat']; + // ['foo', 'baz', 'bar', 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'); + assert.strictEqual(rli.historyIndex, 2); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); // 'bar' + assert.strictEqual(rli.line, 'bar'); + assert.strictEqual(rli.historyIndex, 1); + fi.emit('keypress', '.', { name: 'n', ctrl: true }); + assert.strictEqual(rli.line, 'bat'); + assert.strictEqual(rli.historyIndex, 0); + // Activate the substring history search. + fi.emit('keypress', '.', { name: 'down' }); // 'bat' + assert.strictEqual(rli.line, 'bat'); + assert.strictEqual(rli.historyIndex, -1); + // Deactivate substring history search. + fi.emit('keypress', '.', { name: 'backspace' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + // Activate the substring history search. + fi.emit('keypress', '.', { name: 'down' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'down' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'up' }); // 'bat' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, 'bat'); + fi.emit('keypress', '.', { name: 'up' }); // 'bar' + assert.strictEqual(rli.historyIndex, 1); + assert.strictEqual(rli.line, 'bar'); + fi.emit('keypress', '.', { name: 'up' }); // 'baz' + assert.strictEqual(rli.historyIndex, 2); + assert.strictEqual(rli.line, 'baz'); + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, 'ba'); + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, 'ba'); + // Deactivate substring history search and reset history index. + fi.emit('keypress', '.', { name: 'right' }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, 'ba'); + // Substring history search activated. + fi.emit('keypress', '.', { name: 'up' }); // 'ba' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, 'bat'); + rli.close(); +} + +// Duplicate lines are not removed from history when +// `options.removeHistoryDuplicates` is `false` +{ + const [rli, fi] = getInterface({ + 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(); +} + +// Regression test for repl freeze, #1968: +// check that nothing fails if 'keypress' event throws. +{ + const [rli, fi] = getInterface({ terminal: true }); + const keys = []; + const err = new Error('bad thing happened'); + fi.on('keypress', function(key) { + keys.push(key); + if (key === 'X') { + throw err; + } + }); + assert.throws( + () => fi.emit('data', 'fooX'), + (e) => { + assert.strictEqual(e, err); + return true; + } + ); + fi.emit('data', 'bar'); + assert.strictEqual(keys.join(''), 'fooXbar'); + rli.close(); +} + +// History is bound +{ + const [rli, fi] = getInterface({ terminal: true, 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 [rli] = getInterface({ terminal: true }); + const expectedLines = ['foo']; + rli.question(expectedLines[0]).then(() => rli.close()); + assertCursorRowsAndCols(rli, 0, expectedLines[0].length); + rli.close(); +} + +// Sending a multi-line question +{ + const [rli] = getInterface({ terminal: true }); + const expectedLines = ['foo', 'bar']; + rli.question(expectedLines.join('\n')).then(() => rli.close()); + assertCursorRowsAndCols( + rli, expectedLines.length - 1, expectedLines.slice(-1)[0].length); + rli.close(); +} + +{ + // Beginning and end of line + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + fi.emit('keypress', '.', { ctrl: true, name: 'e' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +{ + // Back and Forward one character + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 18); + // Back one character + fi.emit('keypress', '.', { ctrl: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 17); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 18); + // Forward one character + fi.emit('keypress', '.', { ctrl: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +// Back and Forward one astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Move right one character/code point + fi.emit('keypress', '.', { name: 'right' }); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Two astral characters left +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'left' }); + assertCursorRowsAndCols(rli, 0, 0); + + fi.emit('data', '🐕'); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '🐕💻'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Two astral characters right +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Move left one character/code point + fi.emit('keypress', '.', { name: 'right' }); + assertCursorRowsAndCols(rli, 0, 2); + + fi.emit('data', '🐕'); + assertCursorRowsAndCols(rli, 0, 4); + + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, '💻🐕'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +{ + // `wordLeft` and `wordRight` + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + fi.emit('keypress', '.', { ctrl: true, name: 'left' }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit('keypress', '.', { meta: true, name: 'b' }); + assertCursorRowsAndCols(rli, 0, 10); + fi.emit('keypress', '.', { ctrl: true, name: 'right' }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit('keypress', '.', { meta: true, name: 'f' }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); +} + +// `deleteWordLeft` +[ + { ctrl: true, name: 'w' }, + { ctrl: true, name: 'backspace' }, + { meta: true, name: 'backspace' }, +].forEach((deleteWordLeftKey) => { + let [rli, fi] = getInterface({ terminal: true, prompt: '' }); + 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 + [rli, fi] = getInterface({ terminal: true, prompt: '' }); + 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 [rli, fi] = getInterface({ terminal: true, prompt: '' }); + 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 + [rli, fi] = getInterface({ terminal: true, prompt: '' }); + 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 [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + assertCursorRowsAndCols(rli, 0, 18); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'the quick brown fo'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLeft astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + assertCursorRowsAndCols(rli, 0, 2); + // Delete left character + fi.emit('keypress', '.', { ctrl: true, name: 'h' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteRight +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'he quick brown fox'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteRight astral character +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', '💻'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit('keypress', '.', { ctrl: true, name: 'd' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLineLeft +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete from current to start of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'backspace' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// deleteLineRight +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick brown fox'); + + // Go to the start of the line + fi.emit('keypress', '.', { ctrl: true, name: 'a' }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete from current to end of line + fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Close readline interface +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('keypress', '.', { ctrl: true, name: 'c' }); + assert(rli.closed); +} + +// Multi-line input cursor position +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.columns = 10; + fi.emit('data', 'multi-line text'); + assertCursorRowsAndCols(rli, 1, 5); + rli.close(); +} + +// Multi-line input cursor position and long tabs +{ + const [rli, fi] = getInterface({ tabSize: 16, terminal: true, prompt: '' }); + fi.columns = 10; + fi.emit('data', 'multi-line\ttext \t'); + assert.strictEqual(rli.cursor, 17); + assertCursorRowsAndCols(rli, 3, 2); + rli.close(); +} + +// Check for the default tab size. +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + fi.emit('data', 'the quick\tbrown\tfox'); + assert.strictEqual(rli.cursor, 19); + // The first tab is 7 spaces long, the second one 3 spaces. + assertCursorRowsAndCols(rli, 0, 27); +} + +// Multi-line prompt cursor position +{ + const [rli, fi] = getInterface({ + terminal: true, + prompt: '\nfilledline\nwraping text\n> ' + }); + fi.columns = 10; + fi.emit('data', 't'); + assertCursorRowsAndCols(rli, 4, 3); + rli.close(); +} + +// Clear the whole screen +{ + const [rli, fi] = getInterface({ terminal: true, prompt: '' }); + const lines = ['line 1', 'line 2', 'line 3']; + fi.emit('data', lines.join('\n')); + fi.emit('keypress', '.', { ctrl: true, name: 'l' }); + assertCursorRowsAndCols(rli, 0, 6); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'line 3'); + })); + fi.emit('data', '\n'); + rli.close(); +} + +// Wide characters should be treated as two columns. +assert.strictEqual(getStringWidth('a'), 1); +assert.strictEqual(getStringWidth('あ'), 2); +assert.strictEqual(getStringWidth('谢'), 2); +assert.strictEqual(getStringWidth('고'), 2); +assert.strictEqual(getStringWidth(String.fromCodePoint(0x1f251)), 2); +assert.strictEqual(getStringWidth('abcde'), 5); +assert.strictEqual(getStringWidth('古池や'), 6); +assert.strictEqual(getStringWidth('ノード.js'), 9); +assert.strictEqual(getStringWidth('你好'), 4); +assert.strictEqual(getStringWidth('안녕하세요'), 10); +assert.strictEqual(getStringWidth('A\ud83c\ude00BC'), 5); +assert.strictEqual(getStringWidth('👨‍👩‍👦‍👦'), 8); +assert.strictEqual(getStringWidth('🐕𐐷あ💻😀'), 9); +// TODO(BridgeAR): This should have a width of 4. +assert.strictEqual(getStringWidth('⓬⓪'), 2); +assert.strictEqual(getStringWidth('\u0301\u200D\u200E'), 0); + +// Check if vt control chars are stripped +assert.strictEqual(stripVTControlCharacters('\u001b[31m> \u001b[39m'), '> '); +assert.strictEqual( + stripVTControlCharacters('\u001b[31m> \u001b[39m> '), + '> > ' +); +assert.strictEqual(stripVTControlCharacters('\u001b[31m\u001b[39m'), ''); +assert.strictEqual(stripVTControlCharacters('> '), '> '); +assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m'), 2); +assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m> '), 4); +assert.strictEqual(getStringWidth('\u001b[31m\u001b[39m'), 0); +assert.strictEqual(getStringWidth('> '), 2); + +// 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); +} + +[true, false].forEach(function(terminal) { + // Disable history + { + const [rli, fi] = getInterface({ terminal, historySize: 0 }); + assert.strictEqual(rli.historySize, 0); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, []); + rli.close(); + } + + // Default history size 30 + { + const [rli, fi] = getInterface({ terminal }); + assert.strictEqual(rli.historySize, 30); + + fi.emit('data', 'asdf\n'); + assert.deepStrictEqual(rli.history, terminal ? ['asdf'] : []); + rli.close(); + } + + // Sending a full line + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + } + + // Sending a blank line + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, ''); + })); + fi.emit('data', '\n'); + } + + // Sending a single character with no newline and then a newline + { + const [rli, fi] = getInterface({ terminal }); + let called = false; + rli.on('line', (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 [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length)); + fi.emit('data', `${expectedLines.join('\n')}\n`); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new line + { + const [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\n')); + rli.close(); + } + + // Sending multiple newlines at once that does not end with a new(empty) + // line and a `end` event + { + const [rli, fi] = getInterface({ terminal }); + const expectedLines = ['foo', 'bar', 'baz', '']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + rli.on('close', common.mustCall()); + fi.emit('data', expectedLines.join('\n')); + fi.emit('end'); + rli.close(); + } + + // Sending a multi-byte utf8 char over multiple writes + { + const buf = Buffer.from('☮', 'utf8'); + const [rli, fi] = getInterface({ terminal }); + let callCount = 0; + rli.on('line', function(line) { + callCount++; + assert.strictEqual(line, buf.toString('utf8')); + }); + for (const i of buf) { + fi.emit('data', Buffer.from([i])); + } + assert.strictEqual(callCount, 0); + fi.emit('data', '\n'); + assert.strictEqual(callCount, 1); + rli.close(); + } + + // Calling readline without `new` + { + const [rli, fi] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + rli.close(); + } + + // Calling the question callback + { + const [rli] = getInterface({ terminal }); + rli.question('foo?').then(common.mustCall((answer) => { + assert.strictEqual(answer, 'bar'); + })); + rli.write('bar\n'); + rli.close(); + } + + // Aborting a question + { + const ac = new AbortController(); + const signal = ac.signal; + const [rli] = getInterface({ terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'bar'); + })); + assert.rejects(rli.question('hello?', { signal }), { name: 'AbortError' }) + .then(common.mustCall()); + ac.abort(); + rli.write('bar\n'); + rli.close(); + } + + // Can create a new readline Interface with a null output argument + { + const [rli, fi] = getInterface({ output: null, terminal }); + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, 'asdf'); + })); + fi.emit('data', 'asdf\n'); + + rli.setPrompt('ddd> '); + rli.prompt(); + rli.write("really shouldn't be seeing this"); + rli.question('What do you think of node.js? ', function(answer) { + console.log('Thank you for your valuable feedback:', answer); + rli.close(); + }); + } + + // Calling the getPrompt method + { + const expectedPrompts = ['$ ', '> ']; + const [rli] = getInterface({ terminal }); + for (const prompt of expectedPrompts) { + rli.setPrompt(prompt); + assert.strictEqual(rli.getPrompt(), prompt); + } + } + + { + const expected = terminal ? + ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] : + ['$ ']; + + const output = new Writable({ + write: common.mustCall((chunk, enc, cb) => { + assert.strictEqual(chunk.toString(), expected.shift()); + cb(); + rl.close(); + }, expected.length) + }); + + const rl = readline.createInterface({ + input: new Readable({ read: common.mustCall() }), + output, + prompt: '$ ', + terminal + }); + + rl.prompt(); + + assert.strictEqual(rl.getPrompt(), '$ '); + } + + { + const fi = new FakeInput(); + assert.deepStrictEqual(fi.listeners(terminal ? 'keypress' : 'data'), []); + } + + // Emit two line events when the delay + // between \r and \n exceeds crlfDelay + { + const crlfDelay = 200; + const [rli, fi] = getInterface({ 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, 2); + rli.close(); + }), crlfDelay + 10); + } + + // 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; + + // Set crlfDelay to `Infinity` is allowed + { + const delay = 200; + const [rli, fi] = getInterface({ 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); + } + + // 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 [rli, fi] = getInterface({ terminal, crlfDelay }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1)); + fi.emit('data', expectedLines.join('\r\n')); + rli.close(); + } + + // \r\n should emit one line event when split across multiple writes. + { + const [rli, fi] = getInterface({ terminal, crlfDelay }); + const expectedLines = ['foo', 'bar', 'baz', 'bat']; + let callCount = 0; + rli.on('line', common.mustCall((line) => { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }, expectedLines.length)); + expectedLines.forEach((line) => { + fi.emit('data', `${line}\r`); + fi.emit('data', '\n'); + }); + rli.close(); + } + + // Emit one line event when the delay between \r and \n is + // over the default crlfDelay but within the setting value. + { + const delay = 125; + const [rli, fi] = getInterface({ 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, + output, + terminal: true, + }); + rl.line = `a${' '.repeat(1e6)}a`; + rl.cursor = rl.line.length; +} diff --git a/test/parallel/test-readline-promises-tab-complete.js b/test/parallel/test-readline-promises-tab-complete.js new file mode 100644 index 00000000000000..45a4be359776b8 --- /dev/null +++ b/test/parallel/test-readline-promises-tab-complete.js @@ -0,0 +1,116 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +const readline = require('readline/promises'); +const assert = require('assert'); +const { EventEmitter } = require('events'); +const { getStringWidth } = require('internal/util/inspect'); + +common.skipIfDumbTerminal(); + +// This test verifies that the tab completion supports unicode and the writes +// are limited to the minimum. +[ + 'あ', + '𐐷', + '🐕', +].forEach((char) => { + [true, false].forEach((lineBreak) => { + [ + (line) => [ + ['First group', '', + `${char}${'a'.repeat(10)}`, + `${char}${'b'.repeat(10)}`, + char.repeat(11), + ], + line, + ], + + async (line) => [ + ['First group', '', + `${char}${'a'.repeat(10)}`, + `${char}${'b'.repeat(10)}`, + char.repeat(11), + ], + line, + ], + ].forEach((completer) => { + + let output = ''; + const width = getStringWidth(char) - 1; + + class FakeInput extends EventEmitter { + columns = ((width + 1) * 10 + (lineBreak ? 0 : 10)) * 3 + + write = common.mustCall((data) => { + output += data; + }, 6) + + resume() {} + pause() {} + end() {} + } + + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer: common.mustCallAtLeast(completer), + }); + + const last = '\r\nFirst group\r\n\r\n' + + `${char}${'a'.repeat(10)}${' '.repeat(2 + width * 10)}` + + `${char}${'b'.repeat(10)}` + + (lineBreak ? '\r\n' : ' '.repeat(2 + width * 10)) + + `${char.repeat(11)}\r\n` + + `\r\n\u001b[1G\u001b[0J> ${char}\u001b[${4 + width}G`; + + const expectations = [char, '', last]; + + rli.on('line', common.mustNotCall()); + for (const character of `${char}\t\t`) { + fi.emit('data', character); + queueMicrotask(() => { + assert.strictEqual(output, expectations.shift()); + output = ''; + }); + } + rli.close(); + }); + }); +}); + +{ + let output = ''; + class FakeInput extends EventEmitter { + columns = 80 + + write = common.mustCall((data) => { + output += data; + }, 1) + + resume() {} + pause() {} + end() {} + } + + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer: + common.mustCallAtLeast(() => Promise.reject(new Error('message'))), + }); + + rli.on('line', common.mustNotCall()); + fi.emit('data', '\t'); + queueMicrotask(() => { + assert.match(output, /^Tab completion error: Error: message/); + output = ''; + }); + rli.close(); +} diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index bcd95360ee6262..3cdee188c01e7e 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -197,7 +197,12 @@ const customTypesMap = { 'PerformanceObserverEntryList': 'perf_hooks.html#class-performanceobserverentrylist', - 'readline.Interface': 'readline.html#class-interface', + 'readline.Interface': + 'readline.html#class-readlineinterface', + 'readline.InterfaceConstructor': + 'readline.html#class-interfaceconstructor', + 'readlinePromises.Interface': + 'readline.html#class-readlinepromisesinterface', 'repl.REPLServer': 'repl.html#class-replserver', From 707dd77d8636399aefb1cad14a56369a77d4db13 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 2 May 2021 11:47:01 +0200 Subject: [PATCH 3/4] readline: validate `AbortSignal`s and remove unused event listeners PR-URL: https://github.com/nodejs/node/pull/37947 Fixes: https://github.com/nodejs/node/issues/37287 Reviewed-By: Matteo Collina Reviewed-By: Benjamin Gruenbaum Reviewed-By: Robert Nagy --- lib/readline.js | 27 ++++++++++++++++++++++----- lib/readline/promises.js | 17 +++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/readline.js b/lib/readline.js index 1d9e839f2c2a17..759b9ca2469201 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -48,6 +48,7 @@ const { inspect, } = require('internal/util/inspect'); const { promisify } = require('internal/util'); +const { validateAbortSignal } = require('internal/validators'); /** * @typedef {import('./stream.js').Readable} Readable @@ -130,13 +131,22 @@ Interface.prototype.question = function(query, options, cb) { options = typeof options === 'object' && options !== null ? options : {}; if (options.signal) { + validateAbortSignal(options.signal, 'options.signal'); if (options.signal.aborted) { return; } - options.signal.addEventListener('abort', () => { + const onAbort = () => { this[kQuestionCancel](); - }, { once: true }); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => { + options.signal.removeEventListener(onAbort); + }; + cb = typeof cb === 'function' ? (answer) => { + cleanup(); + return cb(answer); + } : cleanup; } if (typeof cb === 'function') { @@ -151,13 +161,20 @@ Interface.prototype.question[promisify.custom] = function(query, options) { } return new Promise((resolve, reject) => { - this.question(query, options, resolve); + let cb = resolve; if (options.signal) { - options.signal.addEventListener('abort', () => { + const onAbort = () => { reject(new AbortError()); - }, { once: true }); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + cb = (answer) => { + options.signal.removeEventListener('abort', onAbort); + resolve(answer); + }; } + + this.question(query, options, cb); }); }; diff --git a/lib/readline/promises.js b/lib/readline/promises.js index 90658e5a5e9c41..649f92f181758c 100644 --- a/lib/readline/promises.js +++ b/lib/readline/promises.js @@ -16,6 +16,7 @@ const { const { AbortError, } = require('internal/errors'); +const { validateAbortSignal } = require('internal/validators'); class Interface extends _Interface { // eslint-disable-next-line no-useless-constructor @@ -24,18 +25,26 @@ class Interface extends _Interface { } question(query, options = {}) { return new Promise((resolve, reject) => { - if (options.signal) { + let cb = resolve; + + if (options?.signal) { + validateAbortSignal(options.signal, 'options.signal'); if (options.signal.aborted) { return reject(new AbortError()); } - options.signal.addEventListener('abort', () => { + const onAbort = () => { this[kQuestionCancel](); reject(new AbortError()); - }, { once: true }); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + cb = (answer) => { + options.signal.removeEventListener('abort', onAbort); + resolve(answer); + }; } - super.question(query, resolve); + super.question(query, cb); }); } } From 341312d78a8b5b4d5ef03429161242dd9d9b9206 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 5 Aug 2021 20:01:33 +0200 Subject: [PATCH 4/4] readline: add `autoCommit` option PR-URL: https://github.com/nodejs/node/pull/37947 Fixes: https://github.com/nodejs/node/issues/37287 Reviewed-By: Matteo Collina Reviewed-By: Benjamin Gruenbaum Reviewed-By: Robert Nagy --- doc/api/readline.md | 16 +++++++++----- lib/internal/readline/promises.js | 35 ++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/doc/api/readline.md b/doc/api/readline.md index b886c5b2bb6578..cf2cf8a15377ce 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -599,12 +599,14 @@ setTimeout(() => ac.abort(), 10000); added: REPLACEME --> -#### `new readlinePromises.Readline(stream)` +#### `new readlinePromises.Readline(stream[, options])` * `stream` {stream.Writable} A [TTY][] stream. +* `options` {Object} + * `autoCommit` {boolean} If `true`, no need to call `rl.commit()`. #### `rl.clearLine(dir)`