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..7484bed50ca192 --- /dev/null +++ b/lib/internal/readline/interface.js @@ -0,0 +1,1194 @@ +'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'); +const emitKeypressEvents = require('internal/readline/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(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; + } + + setPrompt(prompt) { + this[kPrompt] = prompt; + } + + getPrompt() { + return this[kPrompt]; + } + + [kSetRawMode](mode) { + const wasInRawMode = this.input.isRaw; + + if (typeof this.input.setRawMode === 'function') { + this.input.setRawMode(mode); + } + + return wasInRawMode; + } + + 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; + } + + close() { + if (this.closed) return; + this.pause(); + if (this.terminal) { + this[kSetRawMode](false); + } + this.closed = true; + this.emit('close'); + } + + pause() { + if (this.paused) return; + this.input.pause(); + this.paused = true; + this.emit('pause'); + return this; + } + + resume() { + if (!this.paused) return; + this.input.resume(); + this.paused = false; + this.emit('resume'); + return this; + } + + 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 (prefix.length > completeOn.length) { + this[kInsertString](StringPrototypeSlice(prefix, completeOn.length)); + 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 current cursor's position and line + 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]); + } + } + } + } + } + + [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 3bbf597fcce2e7..89d0633b8dcb75 100644 --- a/lib/readline.js +++ b/lib/readline.js @@ -28,41 +28,14 @@ '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 { @@ -71,331 +44,79 @@ const { cursorTo, moveCursor, } = require('internal/readline/callbacks'); -const emitKeypressEvents = require('internal/readline/emitKeypressEvents') - -const { - AbortError, - codes -} = require('internal/errors'); +const emitKeypressEvents = require('internal/readline/emitKeypressEvents'); const { - ERR_INVALID_ARG_VALUE, - ERR_INVALID_CURSOR_POS, -} = codes; -const { - validateAbortSignal, - validateArray, - validateCallback, - validateString, - validateUint32, -} = require('internal/validators'); + promisify, +} = require('internal/util'); const { inspect, - getStringWidth, - stripVTControlCharacters, } = require('internal/util/inspect'); -const EventEmitter = require('events'); const { - charLengthAt, - charLengthLeft, - commonPrefix, - CSI, - emitKeys, - kSubstringSearch, -} = require('internal/readline/utils'); - -const { promisify } = require('internal/util'); + AbortError, +} = require('internal/errors'); -const { clearTimeout, setTimeout } = require('timers'); const { - kEscape, - kClearToLineBeginning, - kClearToLineEnd, - kClearLine, - kClearScreenDown -} = CSI; - - -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'); - -const KEYPRESS_DECODER = Symbol('keypress-decoder'); -const ESCAPE_DECODER = Symbol('escape-decoder'); - -// GNU readline library - keyseq-timeout is 500ms (default) -const ESCAPE_CODE_TIMEOUT = 500; - -function createInterface(input, output, completer, terminal) { - return new Interface(input, output, completer, terminal); -} - + 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 (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 (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; - // 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); - -ObjectDefineProperty(Interface.prototype, 'columns', { - configurable: true, - enumerable: true, - get: function() { - if (this.output && this.output.columns) - return this.output.columns; - return Infinity; - } -}); - -Interface.prototype.setPrompt = function(prompt) { - this._prompt = prompt; -}; - - -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; -}; - - -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); - } -}; - +ObjectSetPrototypeOf(Interface.prototype, _Interface.prototype); +ObjectSetPrototypeOf(Interface, _Interface); Interface.prototype.question = function(query, options, cb) { cb = typeof options === 'function' ? options : cb; @@ -405,24 +126,16 @@ Interface.prototype.question = function(query, options, cb) { if (options.signal.aborted) { return; } - options.signal.addEventListener('abort', () => { this[kQuestionCancel](); }, { once: true }); } if (typeof cb === 'function') { - if (this._questionCallback) { - this.prompt(); - } else { - this._oldPrompt = this._prompt; - this.setPrompt(query); - this._questionCallback = cb; - this.prompt(); - } + FunctionPrototypeCall(_Interface.prototype.question, this, + query, cb); } }; - Interface.prototype.question[promisify.custom] = function(query, options) { options = typeof options === 'object' && options !== null ? options : {}; @@ -441,203 +154,203 @@ Interface.prototype.question[promisify.custom] = function(query, options) { }); }; -function _questionCancel() { - if (this._questionCallback) { - this._questionCallback = null; - this.setPrompt(this._oldPrompt); - this.clearLine(); - } +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); } +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; + }, + }, +}); -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; -}; - - -Interface.prototype.close = function() { - if (this.closed) return; - this.pause(); - if (this.terminal) { - this._setRawMode(false); - } - this.closed = true; - this.emit('close'); -}; - - -Interface.prototype.pause = function() { - if (this.paused) return; - this.input.pause(); - this.paused = true; - this.emit('pause'); - return this; -}; - - -Interface.prototype.resume = function() { - if (!this.paused) return; - this.input.resume(); - this.paused = false; - this.emit('resume'); - return this; -}; - - -Interface.prototype.write = function(d, key) { - if (this.paused) this.resume(); - if (this.terminal) { - this._ttyWrite(d, key); - } else { - this._normalWrite(d); - } -}; - -Interface.prototype._normalWrite = function(b) { - if (b === undefined) { - return; - } - let string = this._decoder.write(b); - if (this._sawReturnAt && - 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; - - if (this.getCursorPos().cols === 0) { - this._refreshLine(); - } else { - this._writeToOutput(c); - } - } -}; - +// 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) => { @@ -648,285 +361,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 (prefix.length > completeOn.length) { - this._insertString(StringPrototypeSlice(prefix, completeOn.length)); - 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 current cursor's position and line -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') { @@ -946,17 +406,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: @@ -968,467 +428,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]); - } - } - } - } -}; - -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](); -}; - -/** - * accepts a readable Stream instance and makes it emit "keypress" events - */ - -function emitKeypressEvents(stream, iface = {}) { - if (stream[KEYPRESS_DECODER]) return; - - stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); - - stream[ESCAPE_DECODER] = emitKeys(stream); - stream[ESCAPE_DECODER].next(); - - const triggerEscape = () => stream[ESCAPE_DECODER].next(''); - const { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface; - let timeoutId; - - function onData(input) { - if (stream.listenerCount('keypress') > 0) { - const string = stream[KEYPRESS_DECODER].write(input); - if (string) { - clearTimeout(timeoutId); - - // This supports characters of length 2. - iface._sawKeyPress = charLengthAt(string, 0) === string.length; - iface.isCompletionEnabled = false; - - let length = 0; - for (const character of new SafeStringIterator(string)) { - length += character.length; - if (length === string.length) { - iface.isCompletionEnabled = true; - } - - try { - stream[ESCAPE_DECODER].next(character); - // Escape letter at the tail position - if (length === string.length && character === kEscape) { - timeoutId = setTimeout(triggerEscape, escapeCodeTimeout); - } - } catch (err) { - // If the generator throws (it could happen in the `keypress` - // event), we need to restart it. - stream[ESCAPE_DECODER] = emitKeys(stream); - stream[ESCAPE_DECODER].next(); - throw err; - } - } - } - } else { - // Nobody's watching anyway - stream.removeListener('data', onData); - stream.on('newListener', onNewListener); - } - } - - function onNewListener(event) { - if (event === 'keypress') { - stream.on('data', onData); - stream.removeListener('newListener', onNewListener); - } - } - - if (stream.listenerCount('keypress') > 0) { - stream.on('data', onData); - } else { - stream.on('newListener', onNewListener); - } -} - -/** - * moves the cursor to the x and y coordinate on the given stream - */ - -function cursorTo(stream, x, y, callback) { - if (callback !== undefined) { - validateCallback(callback); - } - - if (typeof y === 'function') { - callback = y; - y = undefined; - } - - if (NumberIsNaN(x)) - throw new ERR_INVALID_ARG_VALUE('x', x); - if (NumberIsNaN(y)) - throw new ERR_INVALID_ARG_VALUE('y', y); - - if (stream == null || (typeof x !== 'number' && typeof y !== 'number')) { - if (typeof callback === 'function') - process.nextTick(callback, null); - return true; - } - - if (typeof x !== 'number') - throw new ERR_INVALID_CURSOR_POS(); - - const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; - return stream.write(data, callback); -} - -/** - * moves the cursor relative to its current location - */ - -function moveCursor(stream, dx, dy, callback) { - if (callback !== undefined) { - validateCallback(callback); - } - - if (stream == null || !(dx || dy)) { - if (typeof callback === 'function') - process.nextTick(callback, null); - return true; - } - - 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`; - } - - return stream.write(data, callback); -} - -/** - * clears the current line the cursor is on: - * -1 for left of the cursor - * +1 for right of the cursor - * 0 for the entire line - */ - -function clearLine(stream, dir, callback) { - if (callback !== undefined) { - validateCallback(callback); - } - - if (stream === null || stream === undefined) { - if (typeof callback === 'function') - process.nextTick(callback, null); - return true; - } - - const type = dir < 0 ? - kClearToLineBeginning : - dir > 0 ? - kClearToLineEnd : - kClearLine; - return stream.write(type, callback); -} - -/** - * clears the screen from the current position of the cursor down - */ - -function clearScreenDown(stream, callback) { - if (callback !== undefined) { - validateCallback(callback); - } - - if (stream === null || stream === undefined) { - if (typeof callback === 'function') - process.nextTick(callback, null); - return true; - } - - return stream.write(kClearScreenDown, callback); -} - module.exports = { Interface, clearLine, diff --git a/node.gyp b/node.gyp index 642a9767fb64d8..b07142e7b3baa0 100644 --- a/node.gyp +++ b/node.gyp @@ -220,6 +220,7 @@ 'lib/internal/querystring.js', 'lib/internal/readline/callbacks.js', 'lib/internal/readline/emitKeypressEvents.js', + 'lib/internal/readline/interface.js', 'lib/internal/readline/utils.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js',