Skip to content

Commit 83324e0

Browse files
committed
repl: add support for multiline history
1 parent 27f98c3 commit 83324e0

6 files changed

Lines changed: 443 additions & 14 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
5+
const bench = common.createBenchmark(main, {
6+
n: [1e1, 1e2],
7+
size: [1e3, 1e4],
8+
}, {
9+
flags: ['--expose-internals'],
10+
});
11+
12+
const bechText = `
13+
a ={
14+
a: 1,
15+
b: 2,
16+
}
17+
18+
\`const a ={
19+
a: 1,
20+
b: 2,
21+
c: 3,
22+
d: [1, 2, 3],
23+
}\`
24+
25+
\`const b = [
26+
1,
27+
2,
28+
3,
29+
4,
30+
]\`
31+
32+
const d = [
33+
{
34+
a: 1,
35+
b: 2,
36+
},
37+
{
38+
a: 3,
39+
b: 4,
40+
}
41+
]
42+
43+
var e = [
44+
{
45+
a: 1,
46+
b: 2,
47+
},
48+
{
49+
a: 3,
50+
b: 4,
51+
c: [
52+
{
53+
a: 1,
54+
b: 2,
55+
},
56+
{
57+
a: 3,
58+
b: 4,
59+
}
60+
]
61+
}
62+
]
63+
64+
a = \`
65+
I am a multiline string
66+
I can be as long as I want\`
67+
68+
69+
b = \`
70+
111111111111
71+
222222222222\`
72+
73+
74+
c = \`
75+
111111=11111
76+
222222222222\`
77+
78+
function sum(a, b) {
79+
return a + b
80+
}
81+
82+
console.log(
83+
'something'
84+
)
85+
`;
86+
87+
async function main({ n, size }) {
88+
const { parseHistoryFromFile } = require('internal/repl/history-utils');
89+
90+
bench.start();
91+
92+
for (let i = 0; i < n; i++) {
93+
parseHistoryFromFile(bechText.repeat(size));
94+
}
95+
96+
bench.end(n);
97+
}

lib/internal/repl/history-utils.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use strict';
2+
3+
const {
4+
Array,
5+
ArrayPrototypeJoin,
6+
ArrayPrototypeReverse,
7+
StringPrototypeCharCodeAt,
8+
StringPrototypeTrimEnd,
9+
} = primordials;
10+
const os = require('os');
11+
12+
const kBrackets = { '{': 1, '[': 1, '(': 1, '}': -1, ']': -1, ')': -1 };
13+
14+
function parseHistoryFromFile(historyText, historySize) {
15+
const lines = historyText.trimEnd().split(os.EOL);
16+
let linesLength = lines.length;
17+
if (linesLength > historySize) linesLength = historySize;
18+
19+
const commands = new Array(linesLength);
20+
let commandsIndex = 0;
21+
const currentCommand = new Array(linesLength);
22+
let currentCommandIndex = 0;
23+
let bracketCount = 0;
24+
let inString = false;
25+
let stringDelimiter = '';
26+
27+
for (let lineIdx = linesLength - 1; lineIdx >= 0; lineIdx--) {
28+
const line = lines[lineIdx];
29+
currentCommand[currentCommandIndex++] = line;
30+
31+
let isConcatenation = false;
32+
let isArrowFunction = false;
33+
let lastChar = '';
34+
35+
for (let charIdx = 0, len = line.length; charIdx < len; charIdx++) {
36+
const char = line[charIdx];
37+
38+
if ((char === "'" || char === '"' || char === '`') &&
39+
(charIdx === 0 || StringPrototypeCharCodeAt(line, charIdx - 1) !== 92)) { // 92 is '\\'
40+
if (!inString) {
41+
inString = true;
42+
stringDelimiter = char;
43+
} else if (char === stringDelimiter) {
44+
inString = false;
45+
}
46+
}
47+
48+
if (!inString) {
49+
const bracketValue = kBrackets[char];
50+
if (bracketValue) bracketCount += bracketValue;
51+
}
52+
53+
lastChar = char;
54+
}
55+
56+
if (!inString) {
57+
const trimmedLine = StringPrototypeTrimEnd(line);
58+
isConcatenation = lastChar === '+';
59+
isArrowFunction = lastChar === '>' && trimmedLine[trimmedLine.length - 2] === '=';
60+
}
61+
62+
if (!inString && bracketCount <= 0 && !isConcatenation && !isArrowFunction) {
63+
commands[commandsIndex++] = ArrayPrototypeJoin(currentCommand.slice(0, currentCommandIndex), '\n');
64+
currentCommandIndex = 0;
65+
bracketCount = 0;
66+
}
67+
}
68+
69+
if (currentCommandIndex > 0) {
70+
commands[commandsIndex++] = ArrayPrototypeJoin(currentCommand.slice(0, currentCommandIndex), '\n');
71+
}
72+
73+
commands.length = commandsIndex;
74+
return ArrayPrototypeReverse(commands);
75+
}
76+
77+
module.exports = { parseHistoryFromFile };

lib/internal/repl/history.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
const {
44
ArrayPrototypeJoin,
5+
ArrayPrototypePush,
56
Boolean,
67
FunctionPrototype,
7-
RegExpPrototypeSymbolSplit,
8+
StringPrototypeSplit,
89
StringPrototypeTrim,
910
} = primordials;
1011

@@ -17,6 +18,7 @@ let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
1718
});
1819
const permission = require('internal/process/permission');
1920
const { clearTimeout, setTimeout } = require('timers');
21+
const { parseHistoryFromFile } = require('internal/repl/history-utils');
2022

2123
const noop = FunctionPrototype;
2224

@@ -97,7 +99,7 @@ function setupHistory(repl, historyPath, ready) {
9799
}
98100

99101
if (data) {
100-
repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
102+
repl.history = parseHistoryFromFile(data, repl.historySize);
101103
} else {
102104
repl.history = [];
103105
}
@@ -134,14 +136,30 @@ function setupHistory(repl, historyPath, ready) {
134136
timer = setTimeout(flushHistory, kDebounceHistoryMS);
135137
}
136138

139+
function parseHistoryData() {
140+
const eol = os.EOL;
141+
const result = [];
142+
const historyLength = repl.history.length;
143+
144+
for (let i = 0; i < historyLength; i++) {
145+
const entry = repl.history[i];
146+
const lines = StringPrototypeSplit(entry, eol);
147+
for (let j = lines.length - 1; j >= 0; j--) {
148+
ArrayPrototypePush(result, lines[j]);
149+
}
150+
}
151+
152+
return ArrayPrototypeJoin(result, eol) + eol;
153+
}
154+
137155
function flushHistory() {
138156
timer = null;
139157
if (writing) {
140158
pending = true;
141159
return;
142160
}
143161
writing = true;
144-
const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
162+
const historyData = parseHistoryData();
145163
fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten);
146164
}
147165

lib/repl.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const {
117117
} = require('internal/util');
118118
const { inspect } = require('internal/util/inspect');
119119
const vm = require('vm');
120+
const os = require('os');
120121

121122
const { runInThisContext, runInContext } = vm.Script.prototype;
122123

@@ -952,6 +953,14 @@ function REPLServer(prompt,
952953
self._domain.emit('error', e.err || e);
953954
}
954955

956+
if (self[kBufferedCommandSymbol]) {
957+
// Remove the first N lines from the self.history array
958+
// where N is the number of lines in the buffered command
959+
const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], os.EOL);
960+
self.history = ArrayPrototypeSlice(self.history, lines.length);
961+
// And replace them with the single command divided into lines
962+
ArrayPrototypeUnshift(self.history, self[kBufferedCommandSymbol] + cmd);
963+
}
955964
// Clear buffer if no SyntaxErrors
956965
self.clearBufferedCommand();
957966
sawCtrlD = false;

test/parallel/test-repl-history-navigation.js

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -236,17 +236,22 @@ const tests = [
236236
// K = Erase in line; 0 = right; 1 = left; 2 = total
237237
expected: [
238238
// 0. Start
239-
'\x1B[1G', '\x1B[0J',
240-
prompt, '\x1B[3G',
241-
// 1. UP
242-
// This exceeds the maximum columns (250):
243-
// Whitespace + prompt + ' // '.length + 'autocompleteMe'.length
244-
// 230 + 2 + 4 + 14
245-
'\x1B[1G', '\x1B[0J',
246-
`${prompt}${' '.repeat(230)} aut`, '\x1B[237G',
247-
' // ocompleteMe', '\x1B[237G',
248-
'\n// 123', '\x1B[237G',
249-
'\x1B[1A', '\x1B[1B', '\x1B[2K', '\x1B[1A',
239+
'\x1B[1G',
240+
'\x1B[0J',
241+
`${prompt}`,
242+
'\x1B[3G',
243+
'\x1B[1G',
244+
'\x1B[0J',
245+
`${prompt}aut`,
246+
'\x1B[6G',
247+
' // ocompleteMe',
248+
'\x1B[6G',
249+
'\n// 123',
250+
'\x1B[6G',
251+
'\x1B[1A',
252+
'\x1B[1B',
253+
'\x1B[2K',
254+
'\x1B[1A',
250255
'\x1B[0K',
251256
// 2. UP
252257
'\x1B[1G', '\x1B[0J',
@@ -633,6 +638,42 @@ const tests = [
633638
],
634639
clean: false
635640
},
641+
{
642+
// Test that the multiline history is correctly navigated and it can be edited
643+
env: { NODE_REPL_HISTORY: defaultHistoryPath },
644+
skip: !process.features.inspector,
645+
test: [
646+
'let a = ``',
647+
ENTER,
648+
'a = `I am a multiline strong',
649+
ENTER,
650+
'which ends here`',
651+
ENTER,
652+
UP,
653+
// press LEFT 19 times to reach the typo
654+
...Array(19).fill(LEFT),
655+
BACKSPACE,
656+
'i',
657+
ENTER,
658+
],
659+
expected: [
660+
prompt, ...'let a = ``',
661+
'undefined\n',
662+
prompt, ...'a = `I am a multiline strong',
663+
'... ',
664+
...'which ends here`',
665+
"'I am a multiline strong\\nwhich ends here'\n",
666+
prompt,
667+
`${prompt}a = \`I am a multiline strong\nwhich ends here\``,
668+
`${prompt}a = \`I am a multiline strong\nwhich ends here\``,
669+
`${prompt}a = \`I am a multiline strng\nwhich ends here\``,
670+
`${prompt}a = \`I am a multiline string\nwhich ends here\``,
671+
`${prompt}a = \`I am a multiline string\nwhich ends here\``,
672+
"'I am a multiline string\\nwhich ends here'\n",
673+
prompt,
674+
],
675+
clean: true
676+
},
636677
];
637678
const numtests = tests.length;
638679

0 commit comments

Comments
 (0)