Skip to content

repl: add possibility to edit multiline commands while adding them #58003

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/api/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,10 @@ 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/58003
description: Added the possibility to add/edit/remove multilines
while adding a multiline command.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/57400
description: The multi-line indicator is now "|" instead of "...".
Expand Down
174 changes: 157 additions & 17 deletions lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,7 @@ 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');
Expand Down Expand Up @@ -131,6 +129,8 @@ const kPrompt = Symbol('_prompt');
const kPushToKillRing = Symbol('_pushToKillRing');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kReverseString = Symbol('_reverseString');
const kLastCommandErrored = Symbol('_lastCommandErrored');
const kQuestionReject = Symbol('_questionReject');
const kRedo = Symbol('_redo');
const kRedoStack = Symbol('_redoStack');
Expand All @@ -151,6 +151,12 @@ const kYank = Symbol('_yank');
const kYanking = Symbol('_yanking');
const kYankPop = Symbol('_yankPop');
const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
const kSavePreviousState = Symbol('_savePreviousState');
const kRestorePreviousState = Symbol('_restorePreviousState');
const kPreviousLine = Symbol('_previousLine');
const kPreviousCursor = Symbol('_previousCursor');
const kPreviousPrevRows = Symbol('_previousPrevRows');
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');

function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
Expand Down Expand Up @@ -430,7 +436,7 @@ class Interface extends InterfaceConstructor {
}
}

[kSetLine](line) {
[kSetLine](line = '') {
this.line = line;
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
}
Expand Down Expand Up @@ -477,15 +483,26 @@ class Interface extends InterfaceConstructor {
// Reversing the multilines is necessary when adding / editing and displaying them
if (reverse) {
// First reverse the lines for proper order, then convert separators
return ArrayPrototypeJoin(
ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
to,
);
return this[kReverseString](line, from, to);
}
// For normal cases (saving to history or non-multiline entries)
return StringPrototypeReplaceAll(line, from, to);
}

[kReverseString](line, from, to) {
const parts = StringPrototypeSplit(line, from);

// This implementation should be faster than
// ArrayPrototypeJoin(ArrayPrototypeReverse(StringPrototypeSplit(line, from)), to);
let result = '';
for (let i = parts.length - 1; i > 0; i--) {
result += parts[i] + to;
}
result += parts[0];

return result;
}

[kAddHistory]() {
if (this.line.length === 0) return '';

Expand All @@ -494,22 +511,28 @@ 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', false);

// This is necessary because each like would be saved in the history while creating
// A new multiline, and we don't want that.
if (this[kIsMultiline] && this.historyIndex === -1) {
ArrayPrototypeShift(this.history);
}
// If the last command errored and we are trying to edit the history to fix it
// Remove the broken one from the history
if (this[kLastCommandErrored] && this.history.length > 0) {
ArrayPrototypeShift(this.history);
}

const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true);

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);
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
}

ArrayPrototypeUnshift(this.history, this.line);
ArrayPrototypeUnshift(this.history, normalizedLine);

// Only store so many
if (this.history.length > this.historySize)
Expand All @@ -521,7 +544,7 @@ class Interface extends InterfaceConstructor {
// 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];
const line = this[kIsMultiline] ? this[kReverseString](this.history[0], '\r', '\r') : this.history[0];

// Emit history event to notify listeners of update
this.emit('history', this.history);
Expand Down Expand Up @@ -938,6 +961,18 @@ class Interface extends InterfaceConstructor {
}
}

[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');
Expand All @@ -947,13 +982,117 @@ class Interface extends InterfaceConstructor {
}

[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]() {
if (!this.terminal) return;

// 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) {
Expand Down Expand Up @@ -1525,6 +1664,7 @@ module.exports = {
kWordRight,
kWriteToOutput,
kMultilinePrompt,
kRestorePreviousState,
kAddNewLineOnTTY,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
};
37 changes: 9 additions & 28 deletions lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ const {
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypePushApply,
ArrayPrototypeReverse,
ArrayPrototypeShift,
ArrayPrototypeSlice,
ArrayPrototypeSome,
Expand Down Expand Up @@ -196,8 +195,8 @@ const {
} = require('internal/vm');
const {
kMultilinePrompt,
kAddNewLineOnTTY,
kLastCommandErrored,
kNormalizeHistoryLineEndings,
} = require('internal/readline/interface');
let nextREPLResourceNumber = 1;
// This prevents v8 code cache from getting confused and using a different
Expand Down Expand Up @@ -361,6 +360,7 @@ function REPLServer(prompt,
this.editorMode = false;
// Context id for use with the inspector protocol.
this[kContextId] = undefined;
this[kLastCommandErrored] = false;

if (this.breakEvalOnSigint && eval_) {
// Allowing this would not reflect user expectations.
Expand Down Expand Up @@ -929,8 +929,6 @@ 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)
Expand All @@ -943,33 +941,15 @@ function REPLServer(prompt,
}

// If error was SyntaxError and not JSON.parse error
if (e) {
if (e instanceof Recoverable && !sawCtrlD) {
// Start buffering data like that:
// {
// ... x: 1
// ... }
// We can start a multiline command
if (e && e instanceof Recoverable && !sawCtrlD) {
if (self.terminal) {
self[kAddNewLineOnTTY]();
} else {
self[kBufferedCommandSymbol] += cmd + '\n';
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);
}
return;
}

if (e) {
Expand Down Expand Up @@ -997,6 +977,7 @@ function REPLServer(prompt,
// Display prompt again (unless we already did by emitting the 'error'
// event on the domain instance).
if (!e) {
self[kLastCommandErrored] = false;
self.displayPrompt();
}
}
Expand Down
Loading
Loading