node/lib/internal/readline/interface.js

1613 lines
46 KiB
JavaScript

'use strict';
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypeReverse,
ArrayPrototypeShift,
ArrayPrototypeUnshift,
DateNow,
FunctionPrototypeCall,
MathCeil,
MathFloor,
MathMax,
MathMaxApply,
NumberIsFinite,
ObjectDefineProperty,
ObjectSetPrototypeOf,
RegExpPrototypeExec,
SafeStringIterator,
StringPrototypeCodePointAt,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeRepeat,
StringPrototypeReplaceAll,
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
Symbol,
SymbolAsyncIterator,
SymbolDispose,
} = primordials;
const {
AbortError,
codes: {
ERR_INVALID_ARG_VALUE,
ERR_USE_AFTER_CLOSE,
},
} = require('internal/errors');
const {
validateAbortSignal,
validateString,
validateUint32,
} = require('internal/validators');
const {
assignFunctionName,
kEmptyObject,
} = require('internal/util');
const {
inspect,
getStringWidth,
stripVTControlCharacters,
} = require('internal/util/inspect');
const EventEmitter = require('events');
const { addAbortListener } = require('internal/events/abort_listener');
const {
charLengthAt,
charLengthLeft,
commonPrefix,
kSubstringSearch,
} = require('internal/readline/utils');
let emitKeypressEvents;
let kFirstEventParam;
const {
clearScreenDown,
cursorTo,
moveCursor,
} = require('internal/readline/callbacks');
const { StringDecoder } = require('string_decoder');
const { ReplHistory } = require('internal/repl/history');
const kMaxUndoRedoStackSize = 2048;
const kMincrlfDelay = 100;
/**
* The end of a line is signaled by either one of the following:
* - \r\n
* - \n
* - \r followed by something other than \n
* - \u2028 (Unicode 'LINE SEPARATOR')
* - \u2029 (Unicode 'PARAGRAPH SEPARATOR')
*/
const lineEnding = /\r?\n|\r(?!\n)|\u2028|\u2029/g;
const kLineObjectStream = Symbol('line object stream');
const kQuestionCancel = Symbol('kQuestionCancel');
const kQuestion = Symbol('kQuestion');
// GNU readline library - keyseq-timeout is 500ms (default)
const ESCAPE_CODE_TIMEOUT = 500;
// Max length of the kill ring
const kMaxLengthOfKillRing = 32;
const kMultilinePrompt = Symbol('| ');
const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
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 kMoveDownOrHistoryNext = Symbol('_moveDownOrHistoryNext');
const kHistoryPrev = Symbol('_historyPrev');
const kMoveUpOrHistoryPrev = Symbol('_moveUpOrHistoryPrev');
const kInsertString = Symbol('_insertString');
const kLine = Symbol('_line');
const kLine_buffer = Symbol('_line_buffer');
const kKillRing = Symbol('_killRing');
const kKillRingCursor = Symbol('_killRingCursor');
const kMoveCursor = Symbol('_moveCursor');
const kNormalWrite = Symbol('_normalWrite');
const kOldPrompt = Symbol('_oldPrompt');
const kOnLine = Symbol('_onLine');
const kSetLine = Symbol('_setLine');
const kPreviousKey = Symbol('_previousKey');
const kPrompt = Symbol('_prompt');
const kPushToKillRing = Symbol('_pushToKillRing');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kLastCommandErrored = Symbol('_lastCommandErrored');
const kQuestionReject = Symbol('_questionReject');
const kRedo = Symbol('_redo');
const kRedoStack = Symbol('_redoStack');
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 kUndo = Symbol('_undo');
const kUndoStack = Symbol('_undoStack');
const kIsMultiline = Symbol('_isMultiline');
const kWordLeft = Symbol('_wordLeft');
const kWordRight = Symbol('_wordRight');
const kWriteToOutput = Symbol('_writeToOutput');
const kYank = Symbol('_yank');
const kYanking = Symbol('_yanking');
const kYankPop = Symbol('_yankPop');
const kSavePreviousState = Symbol('_savePreviousState');
const kRestorePreviousState = Symbol('_restorePreviousState');
const kPreviousLine = Symbol('_previousLine');
const kPreviousCursor = Symbol('_previousCursor');
const kPreviousCursorCols = Symbol('_previousCursorCols');
const kMultilineMove = Symbol('_multilineMove');
const kPreviousPrevRows = Symbol('_previousPrevRows');
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
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 crlfDelay;
let prompt = '> ';
let signal;
if (input?.input) {
// An options object was given
output = input.output;
completer = input.completer;
terminal = input.terminal;
signal = input.signal;
// It is possible to configure the history through the input object
const historySize = input.historySize;
const history = input.history;
const removeHistoryDuplicates = input.removeHistoryDuplicates;
if (input.tabSize !== undefined) {
validateUint32(input.tabSize, 'tabSize', true);
this.tabSize = input.tabSize;
}
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;
input.size = historySize;
input.history = history;
input.removeHistoryDuplicates = removeHistoryDuplicates;
}
this.setupHistoryManager(input);
if (completer !== undefined && typeof completer !== 'function') {
throw new ERR_INVALID_ARG_VALUE('completer', completer);
}
// 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[kIsMultiline] = false;
this[kSubstringSearch] = null;
this.output = output;
this.input = input;
this[kUndoStack] = [];
this[kRedoStack] = [];
this[kPreviousCursorCols] = -1;
// The kill ring is a global list of blocks of text that were previously
// killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
// element will be removed to make room for the latest deletion. With kill
// ring, users are able to recall (yank) or cycle (yank pop) among previously
// killed texts, quite similar to the behavior of Emacs.
this[kKillRing] = [];
this[kKillRingCursor] = 0;
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?.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;
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 {
const disposable = addAbortListener(signal, onAborted);
self.once('close', disposable[SymbolDispose]);
}
}
// Current line
this[kSetLine]('');
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?.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];
}
setupHistoryManager(options) {
this.historyManager = new ReplHistory(this, options);
if (options.onHistoryFileLoaded) {
this.historyManager.initialize(options.onHistoryFileLoaded);
}
ObjectDefineProperty(this, 'history', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.history; },
set(newHistory) { return this.historyManager.history = newHistory; },
});
ObjectDefineProperty(this, 'historyIndex', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.index; },
set(historyIndex) { return this.historyManager.index = historyIndex; },
});
ObjectDefineProperty(this, 'historySize', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.size; },
});
ObjectDefineProperty(this, 'isFlushing', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.isFlushing; },
});
}
[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]);
}
}
[kQuestion](query, cb) {
if (this.closed) {
throw new ERR_USE_AFTER_CLOSE('readline');
}
if (this[kQuestionCallback]) {
this.prompt();
} else {
this[kOldPrompt] = this[kPrompt];
this.setPrompt(query);
this[kQuestionCallback] = cb;
this.prompt();
}
}
[kSetLine](line = '') {
this.line = line;
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
}
[kOnLine](line) {
if (this[kQuestionCallback]) {
const cb = this[kQuestionCallback];
this[kQuestionCallback] = null;
this.setPrompt(this[kOldPrompt]);
cb(line);
} else {
this.emit('line', line);
}
}
[kBeforeEdit](oldText, oldCursor) {
this[kPushToUndoStack](oldText, oldCursor);
}
[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]() {
return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]);
}
[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);
if (this[kIsMultiline]) {
const lines = StringPrototypeSplit(this.line, '\n');
// Write first line with normal prompt
this[kWriteToOutput](this[kPrompt] + lines[0]);
// For continuation lines, add the "|" prefix
for (let i = 1; i < lines.length; i++) {
this[kWriteToOutput](`\n${kMultilinePrompt.description}` + lines[i]);
}
} else {
// 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.closed) {
throw new ERR_USE_AFTER_CLOSE('readline');
}
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.closed) {
throw new ERR_USE_AFTER_CLOSE('readline');
}
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.closed) {
throw new ERR_USE_AFTER_CLOSE('readline');
}
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
) {
if (StringPrototypeCodePointAt(string) === 10) string = StringPrototypeSlice(string, 1);
this[kSawReturnAt] = 0;
}
// Run test() on the new string chunk, not on the entire line buffer.
let newPartContainsEnding = RegExpPrototypeExec(lineEnding, string);
if (newPartContainsEnding !== null) {
if (this[kLine_buffer]) {
string = this[kLine_buffer] + string;
this[kLine_buffer] = null;
lineEnding.lastIndex = 0; // Start the search from the beginning of the string.
newPartContainsEnding = RegExpPrototypeExec(lineEnding, string);
}
this[kSawReturnAt] = StringPrototypeEndsWith(string, '\r') ?
DateNow() :
0;
const indexes = [0, newPartContainsEnding.index, lineEnding.lastIndex];
let nextMatch;
while ((nextMatch = RegExpPrototypeExec(lineEnding, string)) !== null) {
ArrayPrototypePush(indexes, nextMatch.index, lineEnding.lastIndex);
}
const lastIndex = indexes.length - 1;
// Either '' or (conceivably) the unfinished portion of the next line
this[kLine_buffer] = StringPrototypeSlice(string, indexes[lastIndex]);
for (let i = 1; i < lastIndex; i += 2) {
this[kOnLine](StringPrototypeSlice(string, indexes[i - 1], indexes[i]));
}
} else if (string) {
// No newlines this time, save what we have for next time
if (this[kLine_buffer]) {
this[kLine_buffer] += string;
} else {
this[kLine_buffer] = string;
}
}
}
[kInsertString](c) {
this[kBeforeEdit](this.line, this.cursor);
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[kSetLine](beg + c + end);
this.cursor += c.length;
this[kRefreshLine]();
} else {
const oldPos = this.getCursorPos();
this.line += c;
this.cursor += c.length;
const newPos = this.getCursorPos();
if (oldPos.rows < newPos.rows) {
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[kSetLine](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[kRefreshLine]();
return;
}
if (!lastKeypressWasTab) {
return;
}
this[kBeforeEdit](this.line, this.cursor);
// 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 = RegExpPrototypeExec(/^\s*(?:[^\w\s]+|\w+)?/, reversed);
this[kMoveCursor](-match[0].length);
}
}
[kWordRight]() {
if (this.cursor < this.line.length) {
const trailing = StringPrototypeSlice(this.line, this.cursor);
const match = RegExpPrototypeExec(/^(?:\s+|[^\w\s]+|\w+)\s*/, trailing);
this[kMoveCursor](match[0].length);
}
}
[kDeleteLeft]() {
if (this.cursor > 0 && this.line.length > 0) {
this[kBeforeEdit](this.line, this.cursor);
// 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) {
this[kBeforeEdit](this.line, this.cursor);
// 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) {
this[kBeforeEdit](this.line, this.cursor);
// 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 = RegExpPrototypeExec(/^\s*(?:[^\w\s]+|\w+)?/, reversed);
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) {
this[kBeforeEdit](this.line, this.cursor);
const trailing = StringPrototypeSlice(this.line, this.cursor);
const match = RegExpPrototypeExec(/^(?:\s+|\W+|\w+)\s*/, trailing);
this.line =
StringPrototypeSlice(this.line, 0, this.cursor) +
StringPrototypeSlice(trailing, match[0].length);
this[kRefreshLine]();
}
}
[kDeleteLineLeft]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, 0, this.cursor);
this[kSetLine](StringPrototypeSlice(this.line, this.cursor));
this.cursor = 0;
this[kPushToKillRing](del);
this[kRefreshLine]();
}
[kDeleteLineRight]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, this.cursor);
this[kSetLine](StringPrototypeSlice(this.line, 0, this.cursor));
this[kPushToKillRing](del);
this[kRefreshLine]();
}
[kPushToKillRing](del) {
if (!del || del === this[kKillRing][0]) return;
ArrayPrototypeUnshift(this[kKillRing], del);
this[kKillRingCursor] = 0;
while (this[kKillRing].length > kMaxLengthOfKillRing)
ArrayPrototypePop(this[kKillRing]);
}
[kYank]() {
if (this[kKillRing].length > 0) {
this[kYanking] = true;
this[kInsertString](this[kKillRing][this[kKillRingCursor]]);
}
}
[kYankPop]() {
if (!this[kYanking]) {
return;
}
if (this[kKillRing].length > 1) {
const lastYank = this[kKillRing][this[kKillRingCursor]];
this[kKillRingCursor]++;
if (this[kKillRingCursor] >= this[kKillRing].length) {
this[kKillRingCursor] = 0;
}
const currentYank = this[kKillRing][this[kKillRingCursor]];
const head =
StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
const tail =
StringPrototypeSlice(this.line, this.cursor);
this[kSetLine](head + currentYank + tail);
this.cursor = head.length + currentYank.length;
this[kRefreshLine]();
}
}
[kSavePreviousState]() {
this[kPreviousLine] = this.line;
this[kPreviousCursor] = this.cursor;
this[kPreviousPrevRows] = this.prevRows;
}
[kRestorePreviousState]() {
this[kSetLine](this[kPreviousLine]);
this.cursor = this[kPreviousCursor];
this.prevRows = this[kPreviousPrevRows];
}
clearLine() {
this[kMoveCursor](+Infinity);
this[kWriteToOutput]('\r\n');
this[kSetLine]('');
this.cursor = 0;
this.prevRows = 0;
}
[kLine]() {
this[kSavePreviousState]();
const line = this[kAddHistory]();
this[kUndoStack] = [];
this[kRedoStack] = [];
this.clearLine();
this[kOnLine](line);
}
// TODO(puskin94): edit [kTtyWrite] to make call this function on a new key combination
// to make it add a new line in the middle of a "complete" multiline.
// I tried with shift + enter but it is not detected. Find a new one.
// Make sure to call this[kSavePreviousState](); && this.clearLine();
// before calling this[kAddNewLineOnTTY] to simulate what [kLine] is doing.
// When this function is called, the actual cursor is at the very end of the whole string,
// No matter where the new line was entered.
// This function should only be used when the output is a TTY
[kAddNewLineOnTTY]() {
// Restore terminal state and store current line
this[kRestorePreviousState]();
const originalLine = this.line;
// Split the line at the current cursor position
const beforeCursor = StringPrototypeSlice(this.line, 0, this.cursor);
let afterCursor = StringPrototypeSlice(this.line, this.cursor, this.line.length);
// Add the new line where the cursor is at
this[kSetLine](`${beforeCursor}\n${afterCursor}`);
// To account for the new line
this.cursor += 1;
const hasContentAfterCursor = afterCursor.length > 0;
const cursorIsNotOnFirstLine = this.prevRows > 0;
let needsRewriteFirstLine = false;
// Handle cursor positioning based on different scenarios
if (hasContentAfterCursor) {
const splitBeg = StringPrototypeSplit(beforeCursor, '\n');
// Determine if we need to rewrite the first line
needsRewriteFirstLine = splitBeg.length < 2;
// If the cursor is not on the first line
if (cursorIsNotOnFirstLine) {
const splitEnd = StringPrototypeSplit(afterCursor, '\n');
// If the cursor when I pressed enter was at least on the second line
// I need to completely erase the line where the cursor was pressed because it is possible
// That it was pressed in the middle of the line, hence I need to write the whole line.
// To achieve that, I need to reach the line above the current line coming from the end
const dy = splitEnd.length + 1;
// Calculate how many Xs we need to move on the right to get to the end of the line
const dxEndOfLineAbove = (splitBeg[splitBeg.length - 2] || '').length + kMultilinePrompt.description.length;
moveCursor(this.output, dxEndOfLineAbove, -dy);
// This is the line that was split in the middle
// Just add it to the rest of the line that will be printed later
afterCursor = `${splitBeg[splitBeg.length - 1]}\n${afterCursor}`;
} else {
// Otherwise, go to the very beginning of the first line and erase everything
const dy = StringPrototypeSplit(originalLine, '\n').length;
moveCursor(this.output, 0, -dy);
}
// Erase from the cursor to the end of the line
clearScreenDown(this.output);
if (cursorIsNotOnFirstLine) {
this[kWriteToOutput]('\n');
}
}
if (needsRewriteFirstLine) {
this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`);
} else {
this[kWriteToOutput](kMultilinePrompt.description);
}
// Write the rest and restore the cursor to where the user left it
if (hasContentAfterCursor) {
// Save the cursor pos, we need to come back here
const oldCursor = this.getCursorPos();
// Write everything after the cursor which has been deleted by clearScreenDown
const formattedEndContent = StringPrototypeReplaceAll(
afterCursor,
'\n',
`\n${kMultilinePrompt.description}`,
);
this[kWriteToOutput](formattedEndContent);
const newCursor = this[kGetDisplayPos](this.line);
// Go back to where the cursor was, with relative movement
moveCursor(this.output, oldCursor.cols - newCursor.cols, oldCursor.rows - newCursor.rows);
// Setting how many rows we have on top of the cursor
// Necessary for kRefreshLine
this.prevRows = oldCursor.rows;
} else {
// Setting how many rows we have on top of the cursor
// Necessary for kRefreshLine
this.prevRows = StringPrototypeSplit(this.line, '\n').length - 1;
}
}
[kPushToUndoStack](text, cursor) {
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
kMaxUndoRedoStackSize) {
ArrayPrototypeShift(this[kUndoStack]);
}
}
[kUndo]() {
if (this[kUndoStack].length <= 0) return;
ArrayPrototypePush(
this[kRedoStack],
{ text: this.line, cursor: this.cursor },
);
const entry = ArrayPrototypePop(this[kUndoStack]);
this[kSetLine](entry.text);
this.cursor = entry.cursor;
this[kRefreshLine]();
}
[kRedo]() {
if (this[kRedoStack].length <= 0) return;
ArrayPrototypePush(
this[kUndoStack],
{ text: this.line, cursor: this.cursor },
);
const entry = ArrayPrototypePop(this[kRedoStack]);
this[kSetLine](entry.text);
this.cursor = entry.cursor;
this[kRefreshLine]();
}
[kMultilineMove](direction, splitLines, { rows, cols }) {
const curr = splitLines[rows];
const down = direction === 1;
const adj = splitLines[rows + direction];
const promptLen = kMultilinePrompt.description.length;
let amountToMove;
// Clamp distance to end of current + prompt + next/prev line + newline
const clamp = down ?
curr.length - cols + promptLen + adj.length + 1 :
-cols + 1;
const shouldClamp = cols > adj.length + 1;
if (shouldClamp) {
if (this[kPreviousCursorCols] === -1) {
this[kPreviousCursorCols] = cols;
}
amountToMove = clamp;
} else {
if (down) {
amountToMove = curr.length + 1;
} else {
amountToMove = -adj.length - 1;
}
if (this[kPreviousCursorCols] !== -1) {
if (this[kPreviousCursorCols] <= adj.length) {
amountToMove += this[kPreviousCursorCols] - cols;
this[kPreviousCursorCols] = -1;
} else {
amountToMove = clamp;
}
}
}
this[kMoveCursor](amountToMove);
}
[kMoveDownOrHistoryNext]() {
const cursorPos = this.getCursorPos();
const splitLines = StringPrototypeSplit(this.line, '\n');
if (this[kIsMultiline] && cursorPos.rows < splitLines.length - 1) {
this[kMultilineMove](1, splitLines, cursorPos);
return;
}
this[kPreviousCursorCols] = -1;
this[kHistoryNext]();
}
// 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
// <ctrl> + N. Only show this after two/three UPs or DOWNs, not on the first
// one.
[kHistoryNext]() {
if (!this.historyManager.canNavigateToNext()) { return; }
this[kBeforeEdit](this.line, this.cursor);
this[kSetLine](this.historyManager.navigateToNext(this[kSubstringSearch]));
this.cursor = this.line.length; // Set cursor to end of line.
this[kRefreshLine]();
}
[kMoveUpOrHistoryPrev]() {
const cursorPos = this.getCursorPos();
if (this[kIsMultiline] && cursorPos.rows > 0) {
const splitLines = StringPrototypeSplit(this.line, '\n');
this[kMultilineMove](-1, splitLines, cursorPos);
return;
}
this[kPreviousCursorCols] = -1;
this[kHistoryPrev]();
}
[kHistoryPrev]() {
if (!this.historyManager.canNavigateToPrevious()) { return; }
this[kBeforeEdit](this.line, this.cursor);
this[kSetLine](this.historyManager.navigateToPrevious(this[kSubstringSearch]));
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;
// Only add prefix offset for continuation lines in user input (not prompts)
offset = this[kIsMultiline] ? kMultilinePrompt.description.length : 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 ||= kEmptyObject;
this[kPreviousKey] = key;
let shouldResetPreviousCursorCols = true;
if (!key.meta || key.name !== 'y') {
// Reset yanking state unless we are doing yank pop.
this[kYanking] = false;
}
// Activate or deactivate substring search.
if (
(key.name === 'up' || key.name === 'down') &&
!key.ctrl &&
!key.meta &&
!key.shift
) {
if (this[kSubstringSearch] === null && !this[kIsMultiline]) {
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;
}
}
// Undo & Redo
if (typeof key.sequence === 'string') {
switch (StringPrototypeCodePointAt(key.sequence, 0)) {
case 0x1f:
this[kUndo]();
return;
case 0x1e:
this[kRedo]();
return;
default:
break;
}
}
// 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 <ctrl>-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();
this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
}
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();
this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+D'));
} 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 'y': // Yank killed string
this[kYank]();
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 <ctrl>-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;
case 'y': // Doing yank pop
this[kYankPop]();
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':
shouldResetPreviousCursorCols = false;
this[kMoveUpOrHistoryPrev]();
break;
case 'down':
shouldResetPreviousCursorCols = false;
this[kMoveDownOrHistoryNext]();
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) {
// Erase state of previous searches.
lineEnding.lastIndex = 0;
let nextMatch;
// Keep track of the end of the last match.
let lastIndex = 0;
while ((nextMatch = RegExpPrototypeExec(lineEnding, s)) !== null) {
this[kInsertString](StringPrototypeSlice(s, lastIndex, nextMatch.index));
({ lastIndex } = lineEnding);
this[kLine]();
// Restore lastIndex as the call to kLine could have mutated it.
lineEnding.lastIndex = lastIndex;
}
// This ensures that the last line is written if it doesn't end in a newline.
// Note that the last line may be the first line, in which case this still works.
this[kInsertString](StringPrototypeSlice(s, lastIndex));
}
}
}
if (shouldResetPreviousCursorCols) {
this[kPreviousCursorCols] = -1;
}
}
/**
* Creates an `AsyncIterator` object that iterates through
* each line in the input stream as a string.
* @typedef {{
* [Symbol.asyncIterator]: () => InterfaceAsyncIterator,
* next: () => Promise<string>
* }} InterfaceAsyncIterator
* @returns {InterfaceAsyncIterator}
*/
[SymbolAsyncIterator]() {
if (this[kLineObjectStream] === undefined) {
kFirstEventParam ??= require('internal/events/symbols').kFirstEventParam;
this[kLineObjectStream] = EventEmitter.on(
this, 'line', {
close: ['close'],
highWaterMark: 1024,
[kFirstEventParam]: true,
});
}
return this[kLineObjectStream];
}
}
Interface.prototype[SymbolDispose] = assignFunctionName(SymbolDispose, function() {
this.close();
});
module.exports = {
Interface,
InterfaceConstructor,
kAddHistory,
kDecoder,
kDeleteLeft,
kDeleteLineLeft,
kDeleteLineRight,
kDeleteRight,
kDeleteWordLeft,
kDeleteWordRight,
kGetDisplayPos,
kHistoryNext,
kHistoryPrev,
kInsertString,
kIsMultiline,
kLine,
kLine_buffer,
kMoveCursor,
kNormalWrite,
kOldPrompt,
kOnLine,
kSetLine,
kPreviousKey,
kPrompt,
kQuestion,
kQuestionCallback,
kQuestionCancel,
kQuestionReject,
kRefreshLine,
kSawKeyPress,
kSawReturnAt,
kSetRawMode,
kTabComplete,
kTabCompleter,
kTtyWrite,
kWordLeft,
kWordRight,
kWriteToOutput,
kMultilinePrompt,
kRestorePreviousState,
kAddNewLineOnTTY,
kLastCommandErrored,
};