repl: add support for multiline history

PR-URL: https://github.com/nodejs/node/pull/57400
Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de>
Reviewed-By: Jordan Harband <ljharb@gmail.com>
This commit is contained in:
Giovanni Bucci 2025-04-13 04:58:01 -07:00 committed by GitHub
parent 964e41c5df
commit 4a4aa58fa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 738 additions and 110 deletions

View file

@ -625,7 +625,7 @@ The `replServer.displayPrompt()` method readies the REPL instance for input
from the user, printing the configured `prompt` to a new line in the `output`
and resuming the `input` to accept new input.
When multi-line input is being entered, an ellipsis is printed rather than the
When multi-line input is being entered, a pipe `'|'` is printed rather than the
'prompt'.
When `preserveCursor` is `true`, the cursor placement will not be reset to `0`.
@ -680,6 +680,14 @@ A list of the names of some Node.js modules, e.g., `'http'`.
<!-- YAML
added: v0.1.91
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/57400
description: The multi-line indicator is now "|" instead of "...".
Added support for multi-line history.
It is now possible to "fix" multi-line commands with syntax errors
by visiting the history and editing the command.
When visiting the multiline history from an old node version,
the multiline structure is not preserved.
- version:
- v13.4.0
- v12.17.0

View file

@ -24,8 +24,11 @@ const {
SafeStringIterator,
StringPrototypeCodePointAt,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeRepeat,
StringPrototypeReplaceAll,
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
StringPrototypeTrim,
Symbol,
@ -88,6 +91,10 @@ const ESCAPE_CODE_TIMEOUT = 500;
// Max length of the kill ring
const kMaxLengthOfKillRing = 32;
// TODO(puskin94): make this configurable
const kMultilinePrompt = Symbol('| ');
const kLastCommandErrored = Symbol('_lastCommandErrored');
const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
const kDecoder = Symbol('_decoder');
@ -99,7 +106,9 @@ 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');
@ -109,6 +118,7 @@ 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');
@ -126,12 +136,14 @@ 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 kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
@ -212,6 +224,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
const self = this;
this.line = '';
this[kIsMultiline] = false;
this[kSubstringSearch] = null;
this.output = output;
this.input = input;
@ -336,7 +349,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
}
// Current line
this.line = '';
this[kSetLine]('');
input.resume();
}
@ -410,6 +423,11 @@ class Interface extends InterfaceConstructor {
}
}
[kSetLine](line) {
this.line = line;
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
}
[kOnLine](line) {
if (this[kQuestionCallback]) {
const cb = this[kQuestionCallback];
@ -441,6 +459,20 @@ class Interface extends InterfaceConstructor {
}
}
// Convert newlines to a consistent format for history storage
[kNormalizeHistoryLineEndings](line, from, to) {
// Multiline history entries are saved reversed
if (StringPrototypeIncludes(line, '\r')) {
// First reverse the lines for proper order, then convert separators
return ArrayPrototypeJoin(
ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
to,
);
}
// For normal cases (saving to history or non-multiline entries)
return StringPrototypeReplaceAll(line, from, to);
}
[kAddHistory]() {
if (this.line.length === 0) return '';
@ -449,8 +481,15 @@ class Interface extends InterfaceConstructor {
// If the trimmed line is empty then return the line
if (StringPrototypeTrim(this.line).length === 0) return this.line;
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r');
if (this.history.length === 0 || this.history[0] !== this.line) {
if (this.history.length === 0 || this.history[0] !== normalizedLine) {
if (this[kLastCommandErrored] && this.historyIndex === 0) {
// If the last command errored, remove it from history.
// The user is issuing a new command starting from the errored command,
// Hopefully with the fix
ArrayPrototypeShift(this.history);
}
if (this.removeHistoryDuplicates) {
// Remove older history line if identical to new one
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
@ -498,8 +537,19 @@ class Interface extends InterfaceConstructor {
// erase data
clearScreenDown(this.output);
// Write the prompt and the current buffer content.
this[kWriteToOutput](line);
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) {
@ -632,7 +682,7 @@ class Interface extends InterfaceConstructor {
this.cursor,
this.line.length,
);
this.line = beg + c + end;
this[kSetLine](beg + c + end);
this.cursor += c.length;
this[kRefreshLine]();
} else {
@ -680,13 +730,13 @@ class Interface extends InterfaceConstructor {
this[kInsertString](StringPrototypeSlice(prefix, completeOn.length));
return;
} else if (!StringPrototypeStartsWith(completeOn, prefix)) {
this.line = StringPrototypeSlice(this.line,
0,
this.cursor - completeOn.length) +
this[kSetLine](StringPrototypeSlice(this.line,
0,
this.cursor - completeOn.length) +
prefix +
StringPrototypeSlice(this.line,
this.cursor,
this.line.length);
this.line.length));
this.cursor = this.cursor - completeOn.length + prefix.length;
this[kRefreshLine]();
return;
@ -825,7 +875,7 @@ class Interface extends InterfaceConstructor {
[kDeleteLineLeft]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, 0, this.cursor);
this.line = StringPrototypeSlice(this.line, this.cursor);
this[kSetLine](StringPrototypeSlice(this.line, this.cursor));
this.cursor = 0;
this[kPushToKillRing](del);
this[kRefreshLine]();
@ -834,7 +884,7 @@ class Interface extends InterfaceConstructor {
[kDeleteLineRight]() {
this[kBeforeEdit](this.line, this.cursor);
const del = StringPrototypeSlice(this.line, this.cursor);
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
this[kSetLine](StringPrototypeSlice(this.line, 0, this.cursor));
this[kPushToKillRing](del);
this[kRefreshLine]();
}
@ -869,7 +919,7 @@ class Interface extends InterfaceConstructor {
StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
const tail =
StringPrototypeSlice(this.line, this.cursor);
this.line = head + currentYank + tail;
this[kSetLine](head + currentYank + tail);
this.cursor = head.length + currentYank.length;
this[kRefreshLine]();
}
@ -878,7 +928,7 @@ class Interface extends InterfaceConstructor {
clearLine() {
this[kMoveCursor](+Infinity);
this[kWriteToOutput]('\r\n');
this.line = '';
this[kSetLine]('');
this.cursor = 0;
this.prevRows = 0;
}
@ -907,7 +957,7 @@ class Interface extends InterfaceConstructor {
);
const entry = ArrayPrototypePop(this[kUndoStack]);
this.line = entry.text;
this[kSetLine](entry.text);
this.cursor = entry.cursor;
this[kRefreshLine]();
@ -922,12 +972,36 @@ class Interface extends InterfaceConstructor {
);
const entry = ArrayPrototypePop(this[kRedoStack]);
this.line = entry.text;
this[kSetLine](entry.text);
this.cursor = entry.cursor;
this[kRefreshLine]();
}
[kMoveDownOrHistoryNext]() {
const { cols, rows } = this.getCursorPos();
const splitLine = StringPrototypeSplit(this.line, '\n');
if (!this.historyIndex && rows === splitLine.length) {
return;
}
// Go to the next history only if the cursor is in the first line of the multiline input.
// Otherwise treat the "arrow down" as a movement to the next row.
if (this[kIsMultiline] && rows < splitLine.length - 1) {
const currentLine = splitLine[rows];
const nextLine = splitLine[rows + 1];
// If I am moving down and the current line is longer than the next line
const amountToMove = (cols > nextLine.length + 1) ?
currentLine.length - cols + nextLine.length +
kMultilinePrompt.description.length + 1 : // Move to the end of the current line
// + chars to account for the kMultilinePrompt prefix, + 1 to go to the first char
currentLine.length + 1; // Otherwise just move to the next line, in the same position
this[kMoveCursor](amountToMove);
return;
}
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!
@ -948,9 +1022,9 @@ class Interface extends InterfaceConstructor {
index--;
}
if (index === -1) {
this.line = search;
this[kSetLine](search);
} else {
this.line = this.history[index];
this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
}
this.historyIndex = index;
this.cursor = this.line.length; // Set cursor to end of line.
@ -958,6 +1032,27 @@ class Interface extends InterfaceConstructor {
}
}
[kMoveUpOrHistoryPrev]() {
const { cols, rows } = this.getCursorPos();
if (this.historyIndex === this.history.length && rows) {
return;
}
// Go to the previous history only if the cursor is in the first line of the multiline input.
// Otherwise treat the "arrow up" as a movement to the previous row.
if (this[kIsMultiline] && rows > 0) {
const splitLine = StringPrototypeSplit(this.line, '\n');
const previousLine = splitLine[rows - 1];
// If I am moving up and the current line is longer than the previous line
const amountToMove = (cols > previousLine.length + 1) ?
-cols + 1 : // Move to the beginning of the current line + 1 char to go to the end of the previous line
-previousLine.length - 1; // Otherwise just move to the previous line, in the same position
this[kMoveCursor](amountToMove);
return;
}
this[kHistoryPrev]();
}
[kHistoryPrev]() {
if (this.historyIndex < this.history.length && this.history.length) {
this[kBeforeEdit](this.line, this.cursor);
@ -971,9 +1066,9 @@ class Interface extends InterfaceConstructor {
index++;
}
if (index === this.history.length) {
this.line = search;
this[kSetLine](search);
} else {
this.line = this.history[index];
this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
}
this.historyIndex = index;
this.cursor = this.line.length; // Set cursor to end of line.
@ -987,11 +1082,13 @@ class Interface extends InterfaceConstructor {
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;
// 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.
@ -1010,8 +1107,10 @@ class Interface extends InterfaceConstructor {
offset += 2;
}
}
const cols = offset % col;
rows += (offset - cols) / col;
return { cols, rows };
}
@ -1024,8 +1123,8 @@ class Interface extends InterfaceConstructor {
* }}
*/
getCursorPos() {
const strBeforeCursor =
this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
const strBeforeCursor = this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
return this[kGetDisplayPos](strBeforeCursor);
}
@ -1074,7 +1173,7 @@ class Interface extends InterfaceConstructor {
!key.meta &&
!key.shift
) {
if (this[kSubstringSearch] === null) {
if (this[kSubstringSearch] === null && !this[kIsMultiline]) {
this[kSubstringSearch] = StringPrototypeSlice(
this.line,
0,
@ -1308,11 +1407,11 @@ class Interface extends InterfaceConstructor {
break;
case 'up':
this[kHistoryPrev]();
this[kMoveUpOrHistoryPrev]();
break;
case 'down':
this[kHistoryNext]();
this[kMoveDownOrHistoryNext]();
break;
case 'tab':
@ -1388,12 +1487,14 @@ module.exports = {
kHistoryNext,
kHistoryPrev,
kInsertString,
kIsMultiline,
kLine,
kLine_buffer,
kMoveCursor,
kNormalWrite,
kOldPrompt,
kOnLine,
kSetLine,
kPreviousKey,
kPrompt,
kQuestion,
@ -1410,4 +1511,7 @@ module.exports = {
kWordLeft,
kWordRight,
kWriteToOutput,
kMultilinePrompt,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
};

View file

@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
}
if (data) {
repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
repl.history = RegExpPrototypeSymbolSplit(/\r?\n+/, data, repl.historySize);
} else {
repl.history = [];
}
@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
return;
}
writing = true;
const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
const historyData = ArrayPrototypeJoin(repl.history, '\n');
fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten);
}

View file

@ -35,6 +35,11 @@ const {
moveCursor,
} = require('internal/readline/callbacks');
const {
kIsMultiline,
kSetLine,
} = require('internal/readline/interface');
const {
commonPrefix,
kSubstringSearch,
@ -361,8 +366,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
}
const showPreview = (showCompletion = true) => {
// Prevent duplicated previews after a refresh.
if (inputPreview !== null || !repl.isCompletionEnabled || !process.features.inspector) {
// Prevent duplicated previews after a refresh or in a multiline command.
if (inputPreview !== null ||
repl[kIsMultiline] ||
!repl.isCompletionEnabled ||
!process.features.inspector) {
return;
}
@ -444,9 +452,14 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
const { cursorPos, displayPos } = getPreviewPos();
const rows = displayPos.rows - cursorPos.rows;
// Moves one line below all the user lines
moveCursor(repl.output, 0, rows);
// Writes the preview there
repl.output.write(`\n${result}`);
// Go back to the horizontal position of the cursor
cursorTo(repl.output, cursorPos.cols);
// Go back to the vertical position of the cursor
moveCursor(repl.output, 0, -rows - 1);
};
@ -672,7 +685,7 @@ function setupReverseSearch(repl) {
// line to the found entry.
if (!isInReverseSearch) {
if (lastMatch !== -1) {
repl.line = repl.history[lastMatch];
repl[kSetLine](repl.history[lastMatch]);
repl.cursor = lastCursor;
repl.historyIndex = lastMatch;
}

View file

@ -53,6 +53,7 @@ const {
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypePushApply,
ArrayPrototypeReverse,
ArrayPrototypeShift,
ArrayPrototypeSlice,
ArrayPrototypeSome,
@ -193,6 +194,11 @@ const {
const {
makeContextifyScript,
} = require('internal/vm');
const {
kMultilinePrompt,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
} = require('internal/readline/interface');
let nextREPLResourceNumber = 1;
// This prevents v8 code cache from getting confused and using a different
// cache from a resource of the same name
@ -923,6 +929,8 @@ function REPLServer(prompt,
debug('finish', e, ret);
ReflectApply(_memory, self, [cmd]);
self[kLastCommandErrored] = false;
if (e && !self[kBufferedCommandSymbol] &&
StringPrototypeStartsWith(StringPrototypeTrim(cmd), 'npm ') &&
!(e instanceof Recoverable)
@ -945,7 +953,28 @@ function REPLServer(prompt,
self.displayPrompt();
return;
}
}
// In the next two if blocks, we do not use os.EOL instead of '\n'
// because on Windows it is '\r\n'
if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
self.history[0] = self[kNormalizeHistoryLineEndings](cmd, '\n', '\r');
} else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
// Remove the first N lines from the self.history array
// where N is the number of lines in the buffered command
const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
self.history = ArrayPrototypeSlice(self.history, lines.length);
lines[lines.length - 1] = cmd;
const newHistoryLine = ArrayPrototypeJoin(ArrayPrototypeReverse(lines), '\r');
if (self.history[0] !== newHistoryLine) {
ArrayPrototypeUnshift(self.history, newHistoryLine);
}
}
if (e) {
self._domain.emit('error', e.err || e);
self[kLastCommandErrored] = true;
}
// Clear buffer if no SyntaxErrors
@ -1185,10 +1214,7 @@ REPLServer.prototype.resetContext = function() {
REPLServer.prototype.displayPrompt = function(preserveCursor) {
let prompt = this._initialPrompt;
if (this[kBufferedCommandSymbol].length) {
prompt = '...';
const len = this.lines.level.length ? this.lines.level.length - 1 : 0;
const levelInd = StringPrototypeRepeat('..', len);
prompt += levelInd + ' ';
prompt = kMultilinePrompt.description;
}
// Do not overwrite `_initialPrompt` here

View file

@ -0,0 +1,4 @@
] } ] } b: 4, a: 3, { c: [{ a: 1, b: 2 }, b: 4, a: 3, { }, b: 2, a: 1, { var d = [
] } b: 2, a: 1, { const c = [
]` 4, 3, 2, 1, `const b = [
I can be as long as I want` I am a multiline string a = `

View file

@ -633,6 +633,191 @@ const tests = [
],
clean: false
},
{
// Test that the multiline history is correctly navigated and it can be edited
env: { NODE_REPL_HISTORY: defaultHistoryPath },
skip: !process.features.inspector,
test: [
'let a = ``',
ENTER,
'a = `I am a multiline strong',
ENTER,
'which ends here`',
ENTER,
UP,
// press LEFT 19 times to reach the typo
...Array(19).fill(LEFT),
BACKSPACE,
'i',
ENTER,
],
expected: [
prompt, ...'let a = ``',
'undefined\n',
prompt, ...'a = `I am a multiline strong', // New Line, the user pressed ENTER
'| ',
...'which ends here`', // New Line, the user pressed ENTER
"'I am a multiline strong\\nwhich ends here'\n", // This is the result printed to the console
prompt,
`${prompt}a = \`I am a multiline strong`, // This is the history being shown and navigated
`\n| which ends here\``,
`${prompt}a = \`I am a multiline strong`, // This is the history being shown and navigated
`\n| which ends here\``,
`${prompt}a = \`I am a multiline strng`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}a = \`I am a multiline string`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}a = \`I am a multiline string`, // This is the history being shown and edited
`\n| which ends here\``,
"'I am a multiline string\\nwhich ends here'\n", // This is the result printed to the console
prompt,
],
clean: true
},
{
// Test that the previous multiline history can only be accessed going through the entirety of the current
// One navigating its all lines first.
env: { NODE_REPL_HISTORY: defaultHistoryPath },
skip: !process.features.inspector,
test: [
'let b = ``',
ENTER,
'b = `I am a multiline strong',
ENTER,
'which ends here`',
ENTER,
'let c = `I',
ENTER,
'am another one`',
ENTER,
UP,
UP,
UP,
UP,
// press RIGHT 10 times to reach the typo
...Array(10).fill(RIGHT),
BACKSPACE,
'i',
ENTER,
],
expected: [
prompt, ...'let b = ``',
'undefined\n',
prompt, ...'b = `I am a multiline strong', // New Line, the user pressed ENTER
'| ',
...'which ends here`', // New Line, the user pressed ENTER
"'I am a multiline strong\\nwhich ends here'\n", // This is the result printed to the console
prompt, ...'let c = `I', // New Line, the user pressed ENTER
'| ',
...'am another one`', // New Line, the user pressed ENTER
'undefined\n',
prompt,
`${prompt}let c = \`I`, // This is the history being shown and navigated
`\n| am another one\``,
`${prompt}let c = \`I`, // This is the history being shown and navigated
`\n| am another one\``,
`${prompt}b = \`I am a multiline strong`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}b = \`I am a multiline strong`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}b = \`I am a multiline strng`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}b = \`I am a multiline string`, // This is the history being shown and edited
`\n| which ends here\``,
`${prompt}b = \`I am a multiline string`, // This is the history being shown and edited
`\n| which ends here\``,
"'I am a multiline string\\nwhich ends here'\n", // This is the result printed to the console
prompt,
],
clean: true
},
{
// Test that we can recover from a line with a syntax error
env: { NODE_REPL_HISTORY: defaultHistoryPath },
skip: !process.features.inspector,
test: [
'let d = ``',
ENTER,
'd = `I am a',
ENTER,
'super',
ENTER,
'broken` line\'',
ENTER,
UP,
BACKSPACE,
'`',
// press LEFT 6 times to reach the typo
...Array(6).fill(LEFT),
BACKSPACE,
ENTER,
],
expected: [
prompt, ...'let d = ``', // New Line, the user pressed ENTER
'undefined\n',
prompt, ...'d = `I am a', // New Line, the user pressed ENTER
'| ',
...'super', // New Line, the user pressed ENTER
'| ',
...'broken` line\'', // New Line, the user pressed ENTER
"[broken` line'\n" +
' ^^^^\n' +
'\n' +
"Uncaught SyntaxError: Unexpected identifier 'line'\n" +
'] {\n' +
' [stack]: [Getter/Setter],\n' +
` [message]: "Unexpected identifier 'line'"\n` +
'}\n',
prompt,
`${prompt}d = \`I am a`, // This is the history being shown and edited
`\n| super`,
`\n| broken\` line'`,
`${prompt}d = \`I am a`, // This is the history being shown and edited
`\n| super`,
'\n| broken` line',
'`',
`${prompt}d = \`I am a`, // This is the history being shown and edited
`\n| super`,
`\n| broken line\``,
"'I am a\\nsuper\\nbroken line'\n", // This is the result printed to the console
prompt,
],
clean: true
},
{
// Test that multiline history is not duplicated
env: { NODE_REPL_HISTORY: defaultHistoryPath },
skip: !process.features.inspector,
test: [
'let f = `multiline',
ENTER,
'string`',
ENTER,
UP, UP, UP,
],
expected: [
prompt, ...'let f = `multiline',
'| ',
...'string`',
'undefined\n',
prompt,
`${prompt}let f = \`multiline`,
`\n| string\``,
`${prompt}let f = \`multiline`,
`\n| string\``,
prompt,
],
clean: true
},
];
const numtests = tests.length;

View file

@ -0,0 +1,96 @@
'use strict';
// Flags: --expose-internals
const common = require('../common');
const assert = require('assert');
const repl = require('internal/repl');
const stream = require('stream');
const fixtures = require('../common/fixtures');
class ActionStream extends stream.Stream {
run(data) {
const _iter = data[Symbol.iterator]();
const doAction = () => {
const next = _iter.next();
if (next.done) {
// Close the repl. Note that it must have a clean prompt to do so.
this.emit('keypress', '', { ctrl: true, name: 'd' });
return;
}
const action = next.value;
if (typeof action === 'object') {
this.emit('keypress', '', action);
}
setImmediate(doAction);
};
doAction();
}
resume() {}
pause() {}
}
ActionStream.prototype.readable = true;
{
// Testing multiline history loading
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
const replHistoryPath = fixtures.path('.node_repl_history_multiline');
const checkResults = common.mustSucceed((r) => {
assert.strictEqual(r.history.length, 4);
r.input.run([{ name: 'up' }]);
assert.strictEqual(r.line, 'var d = [\n' +
' {\n' +
' a: 1,\n' +
' b: 2,\n' +
' },\n' +
' {\n' +
' a: 3,\n' +
' b: 4,\n' +
' c: [{ a: 1, b: 2 },\n' +
' {\n' +
' a: 3,\n' +
' b: 4,\n' +
' }\n' +
' ]\n' +
' }\n' +
']'
);
// Move the cursor all lines up until the former entry is retrieved.
for (let i = 0; i < r.line.split('\n').length; i++) {
r.input.run([{ name: 'up' }]);
}
assert.strictEqual(r.line, 'const c = [\n {\n a: 1,\n b: 2,\n }\n]');
// Move the cursor all lines up until the former entry is retrieved.
for (let i = 0; i < r.line.split('\n').length; i++) {
r.input.run([{ name: 'up' }]);
}
assert.strictEqual(r.line, '`const b = [\n 1,\n 2,\n 3,\n 4,\n]`');
// Move the cursor all lines up until the former entry is retrieved.
for (let i = 0; i < r.line.split('\n').length; i++) {
r.input.run([{ name: 'up' }]);
}
assert.strictEqual(r.line, 'a = `\nI am a multiline string\nI can be as long as I want`');
});
repl.createInternalRepl(
{ NODE_REPL_HISTORY: replHistoryPath },
{
terminal: true,
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _, next) {
next();
}
}),
},
checkResults
);
}

View file

@ -0,0 +1,200 @@
'use strict';
// Flags: --expose-internals
const common = require('../common');
const assert = require('assert');
const repl = require('internal/repl');
const stream = require('stream');
const fs = require('fs');
class ActionStream extends stream.Stream {
run(data) {
const _iter = data[Symbol.iterator]();
const doAction = () => {
const next = _iter.next();
if (next.done) {
// Close the repl. Note that it must have a clean prompt to do so.
this.emit('keypress', '', { ctrl: true, name: 'd' });
return;
}
const action = next.value;
if (typeof action === 'object') {
this.emit('keypress', '', action);
}
setImmediate(doAction);
};
doAction();
}
write(chunk) {
const chunkLines = chunk.toString('utf8').split('\n');
this.lines[this.lines.length - 1] += chunkLines[0];
if (chunkLines.length > 1) {
this.lines.push(...chunkLines.slice(1));
}
this.emit('line', this.lines[this.lines.length - 1]);
return true;
}
resume() {}
pause() {}
}
ActionStream.prototype.readable = true;
function cleanupTmpFile() {
try {
// Write over the file, clearing any history
fs.writeFileSync(defaultHistoryPath, '');
} catch (err) {
if (err.code === 'ENOENT') return true;
throw err;
}
return true;
}
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
{
cleanupTmpFile();
// Make sure the cursor is at the right places.
// If the cursor is at the end of a long line and the down key is pressed,
// Move the cursor to the end of the next line, if shorter.
const checkResults = common.mustSucceed((r) => {
r.write('let str = `');
r.input.run([{ name: 'enter' }]);
r.write('111');
r.input.run([{ name: 'enter' }]);
r.write('22222222222222');
r.input.run([{ name: 'enter' }]);
r.write('3`');
r.input.run([{ name: 'enter' }]);
r.input.run([{ name: 'up' }]);
assert.strictEqual(r.cursor, 33);
r.input.run([{ name: 'up' }]);
assert.strictEqual(r.cursor, 18);
for (let i = 0; i < 5; i++) {
r.input.run([{ name: 'right' }]);
}
r.input.run([{ name: 'up' }]);
assert.strictEqual(r.cursor, 15);
r.input.run([{ name: 'up' }]);
for (let i = 0; i < 5; i++) {
r.input.run([{ name: 'right' }]);
}
assert.strictEqual(r.cursor, 8);
r.input.run([{ name: 'down' }]);
assert.strictEqual(r.cursor, 15);
r.input.run([{ name: 'down' }]);
assert.strictEqual(r.cursor, 19);
});
repl.createInternalRepl(
{ NODE_REPL_HISTORY: defaultHistoryPath },
{
terminal: true,
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _, next) {
next();
}
}),
},
checkResults
);
}
{
cleanupTmpFile();
// If the last command errored and the user is trying to edit it,
// The errored line should be removed from history
const checkResults = common.mustSucceed((r) => {
r.write('let lineWithMistake = `I have some');
r.input.run([{ name: 'enter' }]);
r.write('problem with` my syntax\'');
r.input.run([{ name: 'enter' }]);
r.input.run([{ name: 'up' }]);
r.input.run([{ name: 'backspace' }]);
r.write('`');
for (let i = 0; i < 11; i++) {
r.input.run([{ name: 'left' }]);
}
r.input.run([{ name: 'backspace' }]);
r.input.run([{ name: 'enter' }]);
assert.strictEqual(r.history.length, 1);
assert.strictEqual(r.history[0], 'let lineWithMistake = `I have some\rproblem with my syntax`');
assert.strictEqual(r.line, '');
});
repl.createInternalRepl(
{ NODE_REPL_HISTORY: defaultHistoryPath },
{
terminal: true,
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _, next) {
next();
}
}),
},
checkResults
);
}
{
cleanupTmpFile();
const outputBuffer = [];
// Test that the REPL preview is properly shown on multiline commands
// And deleted when enter is pressed
const checkResults = common.mustSucceed((r) => {
r.write('Array(100).fill(');
r.input.run([{ name: 'enter' }]);
r.write('123');
r.input.run([{ name: 'enter' }]);
r.write(')');
r.input.run([{ name: 'enter' }]);
r.input.run([{ name: 'up' }]);
r.input.run([{ name: 'up' }]);
assert.deepStrictEqual(r.last, new Array(100).fill(123));
r.input.run([{ name: 'enter' }]);
assert.strictEqual(outputBuffer.includes('[\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
' 123\n' +
']\n'), true);
});
repl.createInternalRepl(
{ NODE_REPL_HISTORY: defaultHistoryPath },
{
preview: true,
terminal: true,
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _, next) {
// Store each chunk in the buffer
outputBuffer.push(chunk.toString());
next();
}
}),
},
checkResults
);
}

View file

@ -26,7 +26,7 @@ function run({ useColors }) {
// Validate the output, which contains terminal escape codes.
assert.strictEqual(actual.length, 6);
assert.ok(actual[0].endsWith(input[0]));
assert.ok(actual[1].includes('... '));
assert.ok(actual[1].includes('| '));
assert.ok(actual[1].endsWith(input[1]));
assert.ok(actual[2].includes('undefined'));
assert.ok(actual[3].endsWith(input[2]));

View file

@ -9,7 +9,6 @@ const REPL = require('internal/repl');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const util = require('util');
if (process.env.TERM === 'dumb') {
common.skip('skipping - dumb terminal');
@ -41,11 +40,11 @@ class ActionStream extends stream.Stream {
if (typeof action === 'object') {
this.emit('keypress', '', action);
} else {
this.emit('data', `${action}\n`);
this.emit('data', action);
}
setImmediate(doAction);
};
setImmediate(doAction);
doAction();
}
resume() {}
pause() {}
@ -97,8 +96,8 @@ const tests = [
test: [UP, '21', ENTER, "'42'", ENTER],
expected: [
prompt,
'2', '1', '21\n', prompt, prompt,
"'", '4', '2', "'", "'42'\n", prompt, prompt,
'2', '1', '21\n', prompt,
"'", '4', '2', "'", "'42'\n", prompt,
],
clean: false
},
@ -195,8 +194,6 @@ function runTest(assertCleaned) {
const opts = tests.shift();
if (!opts) return; // All done
console.log('NEW');
if (assertCleaned) {
try {
assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), '');
@ -221,7 +218,6 @@ function runTest(assertCleaned) {
output: new stream.Writable({
write(chunk, _, next) {
const output = chunk.toString();
console.log('INPUT', util.inspect(output));
// Ignore escapes and blank lines
if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output))

View file

@ -7,7 +7,6 @@ const REPL = require('repl');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const util = require('util');
if (process.env.TERM === 'dumb') {
common.skip('skipping - dumb terminal');
@ -39,11 +38,11 @@ class ActionStream extends stream.Stream {
if (typeof action === 'object') {
this.emit('keypress', '', action);
} else {
this.emit('data', `${action}\n`);
this.emit('data', action);
}
setImmediate(doAction);
};
setImmediate(doAction);
doAction();
}
resume() {}
pause() {}
@ -95,10 +94,8 @@ const tests = [
test: [UP, '21', ENTER, "'42'", ENTER],
expected: [
prompt,
// TODO(BridgeAR): The line is refreshed too many times. The double prompt
// is redundant and can be optimized away.
'2', '1', '21\n', prompt, prompt,
"'", '4', '2', "'", "'42'\n", prompt, prompt,
'2', '1', '21\n', prompt,
"'", '4', '2', "'", "'42'\n", prompt,
],
clean: false
},
@ -191,8 +188,6 @@ function runTest(assertCleaned) {
const opts = tests.shift();
if (!opts) return; // All done
console.log('NEW');
if (assertCleaned) {
try {
assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), '');
@ -218,7 +213,6 @@ function runTest(assertCleaned) {
output: new stream.Writable({
write(chunk, _, next) {
const output = chunk.toString();
console.log('INPUT', util.inspect(output));
// Ignore escapes and blank lines
if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output))

View file

@ -18,7 +18,7 @@ function customEval(code, context, file, cb) {
const putIn = new ArrayStream();
putIn.write = function(msg) {
if (msg === '... ') {
if (msg === '| ') {
recovered = true;
}

View file

@ -28,7 +28,9 @@ class ActionStream extends stream.Stream {
const next = _iter.next();
if (next.done) {
// Close the repl. Note that it must have a clean prompt to do so.
this.emit('keypress', '', { ctrl: true, name: 'd' });
setImmediate(() => {
this.emit('keypress', '', { ctrl: true, name: 'd' });
});
return;
}
const action = next.value;
@ -36,7 +38,7 @@ class ActionStream extends stream.Stream {
if (typeof action === 'object') {
this.emit('keypress', '', action);
} else {
this.emit('data', `${action}`);
this.emit('data', action);
}
setImmediate(doAction);
};

View file

@ -96,7 +96,7 @@ async function ordinaryTests() {
['const m = foo(await koo());'],
['m', '4'],
['const n = foo(await\nkoo());',
['const n = foo(await\r', '... koo());\r', 'undefined']],
['const n = foo(await\r', '| koo());\r', 'undefined']],
['n', '4'],
// eslint-disable-next-line no-template-curly-in-string
['`status: ${(await Promise.resolve({ status: 200 })).status}`',
@ -145,20 +145,20 @@ async function ordinaryTests() {
],
['for (const x of [1,2,3]) {\nawait x\n}', [
'for (const x of [1,2,3]) {\r',
'... await x\r',
'... }\r',
'| await x\r',
'| }\r',
'undefined',
]],
['for (const x of [1,2,3]) {\nawait x;\n}', [
'for (const x of [1,2,3]) {\r',
'... await x;\r',
'... }\r',
'| await x;\r',
'| }\r',
'undefined',
]],
['for await (const x of [1,2,3]) {\nconsole.log(x)\n}', [
'for await (const x of [1,2,3]) {\r',
'... console.log(x)\r',
'... }\r',
'| console.log(x)\r',
'| }\r',
'1',
'2',
'3',
@ -166,8 +166,8 @@ async function ordinaryTests() {
]],
['for await (const x of [1,2,3]) {\nconsole.log(x);\n}', [
'for await (const x of [1,2,3]) {\r',
'... console.log(x);\r',
'... }\r',
'| console.log(x);\r',
'| }\r',
'1',
'2',
'3',
@ -198,7 +198,7 @@ async function ordinaryTests() {
} else if ('line' in options) {
assert.strictEqual(lines[toBeRun.length + options.line], expected);
} else {
const echoed = toBeRun.map((a, i) => `${i > 0 ? '... ' : ''}${a}\r`);
const echoed = toBeRun.map((a, i) => `${i > 0 ? '| ' : ''}${a}\r`);
assert.deepStrictEqual(lines, [...echoed, expected, PROMPT]);
}
}

View file

@ -12,7 +12,7 @@ const child = spawn(process.execPath, args);
const input = 'const foo = "bar\\\nbaz"';
// Match '...' as well since it marks a multi-line statement
const expectOut = /> \.\.\. undefined\n/;
const expectOut = /> \| undefined\n/;
child.stderr.setEncoding('utf8');
child.stderr.on('data', (d) => {

View file

@ -69,7 +69,7 @@ async function runReplTests(socket, prompt, tests) {
// Allow to match partial text if no newline was received, because
// sending newlines from the REPL itself would be redundant
// (e.g. in the `... ` multiline prompt: The user already pressed
// (e.g. in the `| ` multiline prompt: The user already pressed
// enter for that, so the REPL shouldn't do it again!).
if (lineBuffer === expectedLine && !expectedLine.includes('\n'))
lineBuffer += '\n';
@ -154,7 +154,7 @@ const errorTests = [
// Common syntax error is treated as multiline command
{
send: 'function test_func() {',
expect: '... '
expect: '| '
},
// You can recover with the .break command
{
@ -169,7 +169,7 @@ const errorTests = [
// Can handle multiline template literals
{
send: '`io.js',
expect: '... '
expect: '| '
},
// Special REPL commands still available
{
@ -179,7 +179,7 @@ const errorTests = [
// Template expressions
{
send: '`io.js ${"1.0"',
expect: '... '
expect: '| '
},
{
send: '+ ".2"}`',
@ -187,7 +187,7 @@ const errorTests = [
},
{
send: '`io.js ${',
expect: '... '
expect: '| '
},
{
send: '"1.0" + ".2"}`',
@ -196,7 +196,7 @@ const errorTests = [
// Dot prefix in multiline commands aren't treated as commands
{
send: '("a"',
expect: '... '
expect: '| '
},
{
send: '.charAt(0))',
@ -330,7 +330,7 @@ const errorTests = [
// Multiline object
{
send: '{ a: ',
expect: '... '
expect: '| '
},
{
send: '1 }',
@ -339,7 +339,7 @@ const errorTests = [
// Multiline string-keyed object (e.g. JSON)
{
send: '{ "a": ',
expect: '... '
expect: '| '
},
{
send: '1 }',
@ -348,12 +348,12 @@ const errorTests = [
// Multiline class with private member.
{
send: 'class Foo { #private = true ',
expect: '... '
expect: '| '
},
// Class field with bigint.
{
send: 'num = 123456789n',
expect: '... '
expect: '| '
},
// Static class features.
{
@ -363,15 +363,15 @@ const errorTests = [
// Multiline anonymous function with comment
{
send: '(function() {',
expect: '... '
expect: '| '
},
{
send: '// blah',
expect: '... '
expect: '| '
},
{
send: 'return 1n;',
expect: '... '
expect: '| '
},
{
send: '})()',
@ -380,11 +380,11 @@ const errorTests = [
// Multiline function call
{
send: 'function f(){}; f(f(1,',
expect: '... '
expect: '| '
},
{
send: '2)',
expect: '... '
expect: '| '
},
{
send: ')',
@ -405,16 +405,16 @@ const errorTests = [
...possibleTokensAfterIdentifierWithLineBreak.map((token) => (
{
send: `npm ${token}; undefined`,
expect: '... undefined'
expect: '| undefined'
}
)),
{
send: '(function() {\n\nreturn 1;\n})()',
expect: '... ... ... 1'
expect: '| | | 1'
},
{
send: '{\n\na: 1\n}',
expect: '... ... ... { a: 1 }'
expect: '| | | { a: 1 }'
},
{
send: 'url.format("http://google.com")',
@ -449,7 +449,7 @@ const errorTests = [
// Do not fail when a String is created with line continuation
{
send: '\'the\\\nfourth\\\neye\'',
expect: ['... ... \'thefourtheye\'']
expect: ['| | \'thefourtheye\'']
},
// Don't fail when a partial String is created and line continuation is used
// with whitespace characters at the end of the string. We are to ignore it.
@ -462,17 +462,17 @@ const errorTests = [
// Multiline strings preserve whitespace characters in them
{
send: '\'the \\\n fourth\t\t\\\n eye \'',
expect: '... ... \'the fourth\\t\\t eye \''
expect: '| | \'the fourth\\t\\t eye \''
},
// More than one multiline strings also should preserve whitespace chars
{
send: '\'the \\\n fourth\' + \'\t\t\\\n eye \'',
expect: '... ... \'the fourth\\t\\t eye \''
expect: '| | \'the fourth\\t\\t eye \''
},
// using REPL commands within a string literal should still work
{
send: '\'\\\n.break',
expect: '... ' + prompt_unix
expect: '| ' + prompt_unix
},
// Using REPL command "help" within a string literal should still work
{
@ -519,7 +519,7 @@ const errorTests = [
// Empty lines in the string literals should not affect the string
{
send: '\'the\\\n\\\nfourtheye\'\n',
expect: '... ... \'thefourtheye\''
expect: '| | \'thefourtheye\''
},
// Regression test for https://github.com/nodejs/node/issues/597
{
@ -536,32 +536,32 @@ const errorTests = [
// Regression tests for https://github.com/nodejs/node/issues/2749
{
send: 'function x() {\nreturn \'\\n\';\n }',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x() {\nreturn \'\\\\\';\n }',
expect: '... ... undefined'
expect: '| | undefined'
},
// Regression tests for https://github.com/nodejs/node/issues/3421
{
send: 'function x() {\n//\'\n }',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x() {\n//"\n }',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x() {//\'\n }',
expect: '... undefined'
expect: '| undefined'
},
{
send: 'function x() {//"\n }',
expect: '... undefined'
expect: '| undefined'
},
{
send: 'function x() {\nvar i = "\'";\n }',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x(/*optional*/) {}',
@ -589,7 +589,7 @@ const errorTests = [
},
{
send: '/* \'\n"\n\'"\'\n*/',
expect: '... ... ... undefined'
expect: '| | | undefined'
},
// REPL should get a normal require() function, not one that allows
// access to internal modules without the --expose-internals flag.
@ -614,19 +614,19 @@ const errorTests = [
// REPL should handle quotes within regexp literal in multiline mode
{
send: "function x(s) {\nreturn s.replace(/'/,'');\n}",
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: "function x(s) {\nreturn s.replace(/'/,'');\n}",
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x(s) {\nreturn s.replace(/"/,"");\n}',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: 'function x(s) {\nreturn s.replace(/.*/,"");\n}',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: '{ var x = 4; }',
@ -697,17 +697,17 @@ const errorTests = [
// https://github.com/nodejs/node/issues/9300
{
send: 'function foo() {\nvar bar = 1 / 1; // "/"\n}',
expect: '... ... undefined'
expect: '| | undefined'
},
{
send: '(function() {\nreturn /foo/ / /bar/;\n}())',
expect: '... ... NaN'
expect: '| | NaN'
},
{
send: '(function() {\nif (false) {} /bar"/;\n}())',
expect: '... ... undefined'
expect: '| | undefined'
},
// https://github.com/nodejs/node/issues/16483
@ -723,7 +723,7 @@ const errorTests = [
// Newline within template string maintains whitespace.
{
send: '`foo \n`',
expect: '... \'foo \\n\''
expect: '| \'foo \\n\''
},
// Whitespace is not evaluated.
{
@ -757,7 +757,7 @@ const errorTests = [
{
send: 'x = {\nfield\n{',
expect: [
'... ... {',
'| | {',
kArrow,
'',
/^Uncaught SyntaxError: /,
@ -774,11 +774,11 @@ const errorTests = [
},
{
send: 'if (typeof process === "object"); {',
expect: '... '
expect: '| '
},
{
send: 'console.log("process is defined");',
expect: '... '
expect: '| '
},
{
send: '} else {',