diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js new file mode 100644 index 00000000000000..e50172f5628ccc --- /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, false /* stripVTControlCharacters */); + 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, +};