Skip to content

Commit 7353efd

Browse files
committed
repl: add possibility to edit multiline commands while adding them
1 parent c11c7be commit 7353efd

4 files changed

Lines changed: 392 additions & 44 deletions

File tree

lib/internal/readline/interface.js

Lines changed: 159 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ const kMaxLengthOfKillRing = 32;
100100

101101
// TODO(puskin94): make this configurable
102102
const kMultilinePrompt = Symbol('| ');
103-
const kLastCommandErrored = Symbol('_lastCommandErrored');
104103

105104
const kAddHistory = Symbol('_addHistory');
106105
const kBeforeEdit = Symbol('_beforeEdit');
@@ -131,6 +130,8 @@ const kPrompt = Symbol('_prompt');
131130
const kPushToKillRing = Symbol('_pushToKillRing');
132131
const kPushToUndoStack = Symbol('_pushToUndoStack');
133132
const kQuestionCallback = Symbol('_questionCallback');
133+
const kReverseString = Symbol('_reverseString');
134+
const kLastCommandErrored = Symbol('_lastCommandErrored');
134135
const kQuestionReject = Symbol('_questionReject');
135136
const kRedo = Symbol('_redo');
136137
const kRedoStack = Symbol('_redoStack');
@@ -151,6 +152,12 @@ const kYank = Symbol('_yank');
151152
const kYanking = Symbol('_yanking');
152153
const kYankPop = Symbol('_yankPop');
153154
const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
155+
const kSavePreviousState = Symbol('_savePreviousState');
156+
const kRestorePreviousState = Symbol('_restorePreviousState');
157+
const kPreviousLine = Symbol('_previousLine');
158+
const kPreviousCursor = Symbol('_previousCursor');
159+
const kPreviousPrevRows = Symbol('_previousPrevRows');
160+
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
154161

155162
function InterfaceConstructor(input, output, completer, terminal) {
156163
this[kSawReturnAt] = 0;
@@ -430,7 +437,7 @@ class Interface extends InterfaceConstructor {
430437
}
431438
}
432439

433-
[kSetLine](line) {
440+
[kSetLine](line = '') {
434441
this.line = line;
435442
this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
436443
}
@@ -477,15 +484,26 @@ class Interface extends InterfaceConstructor {
477484
// Reversing the multilines is necessary when adding / editing and displaying them
478485
if (reverse) {
479486
// First reverse the lines for proper order, then convert separators
480-
return ArrayPrototypeJoin(
481-
ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
482-
to,
483-
);
487+
return this[kReverseString](line, from, to);
484488
}
485489
// For normal cases (saving to history or non-multiline entries)
486490
return StringPrototypeReplaceAll(line, from, to);
487491
}
488492

493+
[kReverseString](line, from, to) {
494+
const parts = StringPrototypeSplit(line, from);
495+
496+
// This implementation should be faster than
497+
// ArrayPrototypeJoin(ArrayPrototypeReverse(StringPrototypeSplit(line, from)), to);
498+
let result = '';
499+
for (let i = parts.length - 1; i > 0; i--) {
500+
result += parts[i] + to;
501+
}
502+
result += parts[0];
503+
504+
return result;
505+
}
506+
489507
[kAddHistory]() {
490508
if (this.line.length === 0) return '';
491509

@@ -494,22 +512,30 @@ class Interface extends InterfaceConstructor {
494512

495513
// If the trimmed line is empty then return the line
496514
if (StringPrototypeTrim(this.line).length === 0) return this.line;
515+
516+
// This is necessary because each like would be saved in the history while creating
517+
// A new multiline, and we don't want that.
518+
if (this[kIsMultiline] && this.historyIndex === -1) {
519+
ArrayPrototypeShift(this.history);
520+
}
521+
// If the last command errored and we are trying to edit the history to fix it
522+
// Remove the broken one from the history
523+
if (this[kLastCommandErrored] && this.history.length > 0) {
524+
ArrayPrototypeShift(this.history);
525+
}
526+
497527
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', false);
498528

499529
if (this.history.length === 0 || this.history[0] !== normalizedLine) {
500-
if (this[kLastCommandErrored] && this.historyIndex === 0) {
501-
// If the last command errored, remove it from history.
502-
// The user is issuing a new command starting from the errored command,
503-
// Hopefully with the fix
504-
ArrayPrototypeShift(this.history);
505-
}
506530
if (this.removeHistoryDuplicates) {
507531
// Remove older history line if identical to new one
508532
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
509533
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
510534
}
511535

512-
ArrayPrototypeUnshift(this.history, this.line);
536+
ArrayPrototypeUnshift(this.history, this[kIsMultiline] ?
537+
this[kReverseString](normalizedLine, '\r', '\r') :
538+
normalizedLine);
513539

514540
// Only store so many
515541
if (this.history.length > this.historySize)
@@ -521,7 +547,7 @@ class Interface extends InterfaceConstructor {
521547
// The listener could change the history object, possibly
522548
// to remove the last added entry if it is sensitive and should
523549
// not be persisted in the history, like a password
524-
const line = this.history[0];
550+
const line = this[kIsMultiline] ? this[kReverseString](this.history[0], '\r', '\r') : this.history[0];
525551

526552
// Emit history event to notify listeners of update
527553
this.emit('history', this.history);
@@ -938,6 +964,18 @@ class Interface extends InterfaceConstructor {
938964
}
939965
}
940966

967+
[kSavePreviousState]() {
968+
this[kPreviousLine] = this.line;
969+
this[kPreviousCursor] = this.cursor;
970+
this[kPreviousPrevRows] = this.prevRows;
971+
}
972+
973+
[kRestorePreviousState]() {
974+
this[kSetLine](this[kPreviousLine]);
975+
this.cursor = this[kPreviousCursor];
976+
this.prevRows = this[kPreviousPrevRows];
977+
}
978+
941979
clearLine() {
942980
this[kMoveCursor](+Infinity);
943981
this[kWriteToOutput]('\r\n');
@@ -947,13 +985,110 @@ class Interface extends InterfaceConstructor {
947985
}
948986

949987
[kLine]() {
988+
this[kSavePreviousState]();
950989
const line = this[kAddHistory]();
951990
this[kUndoStack] = [];
952991
this[kRedoStack] = [];
953992
this.clearLine();
954993
this[kOnLine](line);
955994
}
956995

996+
// When this function is called, the actual cursor is at the very end of the whole string,
997+
// No matter where the new line was entered.
998+
// This function should only be used when the output is a TTY
999+
[kAddNewLineOnTTY]() {
1000+
if (!this.terminal) {
1001+
return;
1002+
}
1003+
1004+
this[kRestorePreviousState]();
1005+
const originalLine = this.line;
1006+
1007+
// Before the cursor
1008+
const beg = StringPrototypeSlice(this.line, 0, this.cursor);
1009+
// After the cursor
1010+
let end = StringPrototypeSlice(this.line, this.cursor, this.line.length);
1011+
1012+
// Add the new line where the cursor is at
1013+
this[kSetLine](`${beg}\n${end}`);
1014+
1015+
// To account for the new line
1016+
this.cursor += 1;
1017+
1018+
const hasContentAfterCursor = end.length > 0;
1019+
const cursorIsNotOnFirstLine = this.prevRows > 0;
1020+
const lineParts = hasContentAfterCursor ? StringPrototypeSplit(beg, '\n') : null;
1021+
1022+
// Handle cursor positioning based on different scenarios
1023+
if (hasContentAfterCursor) {
1024+
// If the cursor is not on the first line
1025+
if (cursorIsNotOnFirstLine) {
1026+
const splitEnd = StringPrototypeSplit(end, '\n');
1027+
// If the cursor when I pressed enter was at least on the second line
1028+
// I need to completely erase the line where the cursor was pressed because it is possible
1029+
// That it was pressed in the middle of the line, hence I need to write the whole line.
1030+
// To achieve that, I need to reach the line above the current line coming from the end
1031+
const dy = splitEnd.length + 1;
1032+
// Calculate how many Xs we need to move on the right to get to the end of the line
1033+
const dxEndOfLineAbove = lineParts[lineParts.length - 2].length + kMultilinePrompt.description.length;
1034+
moveCursor(this.output, dxEndOfLineAbove, -dy);
1035+
// This is the line that was split in the middle
1036+
// Just add it to the rest of the line that will be printed later
1037+
end = `${lineParts[lineParts.length - 1]}\n${end}`;
1038+
} else {
1039+
// Otherwise, go to the very beginning of the first line and erase everything
1040+
const dy = StringPrototypeSplit(originalLine, '\n').length;
1041+
moveCursor(this.output, 0, -dy);
1042+
}
1043+
} else {
1044+
// Otherwise, go to the very beginning of the currentLine
1045+
// We need to rewrite everything
1046+
cursorTo(this.output, 0);
1047+
}
1048+
1049+
// Erase from the cursor to the end of the line
1050+
clearScreenDown(this.output);
1051+
1052+
// Handle new line output for multiline case
1053+
if (hasContentAfterCursor && cursorIsNotOnFirstLine) {
1054+
this[kWriteToOutput]('\n');
1055+
}
1056+
1057+
// Determine if we need to rewrite the first line
1058+
const needsRewriteFirstLine = lineParts && lineParts.length < 2;
1059+
if (needsRewriteFirstLine) {
1060+
this[kWriteToOutput](`${this[kPrompt]}${beg}\n${kMultilinePrompt.description}`);
1061+
} else {
1062+
this[kWriteToOutput](kMultilinePrompt.description);
1063+
}
1064+
1065+
// Write the rest and restore the cursor to where the user left it
1066+
if (hasContentAfterCursor) {
1067+
// Save the cursor pos, we need to come back here
1068+
const oldCursor = this.getCursorPos();
1069+
1070+
// Write everything after the cursor which has been deleted by clearScreenDown
1071+
for (const letter of end) {
1072+
if (letter === '\n') {
1073+
this[kWriteToOutput](`\n${kMultilinePrompt.description}`);
1074+
} else {
1075+
this[kWriteToOutput](letter);
1076+
}
1077+
}
1078+
1079+
const newCursor = this[kGetDisplayPos](this.line);
1080+
// Go back to where the cursor was, with relative movement
1081+
moveCursor(this.output, oldCursor.cols - newCursor.cols, oldCursor.rows - newCursor.rows);
1082+
// Setting how many rows we have on top of the cursor
1083+
// Necessary for kRefreshLine
1084+
this.prevRows = oldCursor.rows;
1085+
} else {
1086+
// Setting how many rows we have on top of the cursor
1087+
// Necessary for kRefreshLine
1088+
this.prevRows = StringPrototypeSplit(this.line, '\n').length - 1;
1089+
}
1090+
}
1091+
9571092
[kPushToUndoStack](text, cursor) {
9581093
if (ArrayPrototypePush(this[kUndoStack], { text, cursor }) >
9591094
kMaxUndoRedoStackSize) {
@@ -1370,6 +1505,14 @@ class Interface extends InterfaceConstructor {
13701505
case 'y': // Doing yank pop
13711506
this[kYankPop]();
13721507
break;
1508+
1509+
case 'return': // ((meta|option) + enter) Inserting a new line, this might work on few terminals only
1510+
// The next 2 lines are to standardize the behavior simulating what [kLine]() does.
1511+
// The internal calculations in [kAddNewLineOnTTY] are expecting certain state to be set.
1512+
this[kSavePreviousState]();
1513+
this.clearLine();
1514+
this[kAddNewLineOnTTY]();
1515+
break;
13731516
}
13741517
} else {
13751518
/* No modifier keys used */
@@ -1525,6 +1668,7 @@ module.exports = {
15251668
kWordRight,
15261669
kWriteToOutput,
15271670
kMultilinePrompt,
1671+
kRestorePreviousState,
1672+
kAddNewLineOnTTY,
15281673
kLastCommandErrored,
1529-
kNormalizeHistoryLineEndings,
15301674
};

lib/repl.js

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ const {
5353
ArrayPrototypePop,
5454
ArrayPrototypePush,
5555
ArrayPrototypePushApply,
56-
ArrayPrototypeReverse,
5756
ArrayPrototypeShift,
5857
ArrayPrototypeSlice,
5958
ArrayPrototypeSome,
@@ -196,8 +195,8 @@ const {
196195
} = require('internal/vm');
197196
const {
198197
kMultilinePrompt,
198+
kAddNewLineOnTTY,
199199
kLastCommandErrored,
200-
kNormalizeHistoryLineEndings,
201200
} = require('internal/readline/interface');
202201
let nextREPLResourceNumber = 1;
203202
// This prevents v8 code cache from getting confused and using a different
@@ -361,6 +360,7 @@ function REPLServer(prompt,
361360
this.editorMode = false;
362361
// Context id for use with the inspector protocol.
363362
this[kContextId] = undefined;
363+
this[kLastCommandErrored] = false;
364364

365365
if (this.breakEvalOnSigint && eval_) {
366366
// Allowing this would not reflect user expectations.
@@ -929,8 +929,6 @@ function REPLServer(prompt,
929929
debug('finish', e, ret);
930930
ReflectApply(_memory, self, [cmd]);
931931

932-
self[kLastCommandErrored] = false;
933-
934932
if (e && !self[kBufferedCommandSymbol] &&
935933
StringPrototypeStartsWith(StringPrototypeTrim(cmd), 'npm ') &&
936934
!(e instanceof Recoverable)
@@ -943,33 +941,15 @@ function REPLServer(prompt,
943941
}
944942

945943
// If error was SyntaxError and not JSON.parse error
946-
if (e) {
947-
if (e instanceof Recoverable && !sawCtrlD) {
948-
// Start buffering data like that:
949-
// {
950-
// ... x: 1
951-
// ... }
944+
// We can start a multiline command
945+
if (e && e instanceof Recoverable && !sawCtrlD) {
946+
if (self.terminal) {
947+
self[kAddNewLineOnTTY]();
948+
} else {
952949
self[kBufferedCommandSymbol] += cmd + '\n';
953950
self.displayPrompt();
954-
return;
955-
}
956-
}
957-
958-
// In the next two if blocks, we do not use os.EOL instead of '\n'
959-
// because on Windows it is '\r\n'
960-
if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
961-
self.history[0] = self[kNormalizeHistoryLineEndings](cmd, '\n', '\r');
962-
} else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
963-
// Remove the first N lines from the self.history array
964-
// where N is the number of lines in the buffered command
965-
966-
const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
967-
self.history = ArrayPrototypeSlice(self.history, lines.length);
968-
lines[lines.length - 1] = cmd;
969-
const newHistoryLine = ArrayPrototypeJoin(ArrayPrototypeReverse(lines), '\r');
970-
if (self.history[0] !== newHistoryLine) {
971-
ArrayPrototypeUnshift(self.history, newHistoryLine);
972951
}
952+
return;
973953
}
974954

975955
if (e) {
@@ -997,6 +977,7 @@ function REPLServer(prompt,
997977
// Display prompt again (unless we already did by emitting the 'error'
998978
// event on the domain instance).
999979
if (!e) {
980+
self[kLastCommandErrored] = false;
1000981
self.displayPrompt();
1001982
}
1002983
}

0 commit comments

Comments
 (0)