Skip to content

Commit 8bba55a

Browse files
committed
repl: support previews by eager evaluating input
This adds input previews by using the inspectors eager evaluation functionality. It is implemented as additional line that is not counted towards the actual input. In case no colors are supported, it will be visible as comment. Otherwise it's grey. It will be triggered on any line change. It is heavily tested against edge cases and adheres to "dumb" terminals (previews are deactived in that case). Fixes: #20977
1 parent eac3f0a commit 8bba55a

File tree

7 files changed

+444
-71
lines changed

7 files changed

+444
-71
lines changed

doc/api/repl.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,10 @@ with REPL instances programmatically.
510510
<!-- YAML
511511
added: v0.1.91
512512
changes:
513+
- version: REPLACEME
514+
pr-url: https://github.com/nodejs/node/pull/30811
515+
description: The `preview` option is available from now on. The input
516+
generates output previews from now on.
513517
- version: v12.0.0
514518
pr-url: https://github.com/nodejs/node/pull/26518
515519
description: The `terminal` option now follows the default description in
@@ -562,6 +566,8 @@ changes:
562566
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
563567
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
564568
together with a custom `eval` function. **Default:** `false`.
569+
* `preview` {boolean} Defines if the repl prints output previews or not.
570+
**Default:** `true`. Always `false` in case `terminal` is falsy.
565571
* Returns: {repl.REPLServer}
566572

567573
The `repl.start()` method creates and starts a [`repl.REPLServer`][] instance.

lib/internal/repl/utils.js

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ const {
44
Symbol,
55
} = primordials;
66

7-
const acorn = require('internal/deps/acorn/acorn/dist/acorn');
7+
const { MathMin } = primordials;
8+
9+
const { tokTypes: tt, Parser: AcornParser } =
10+
require('internal/deps/acorn/acorn/dist/acorn');
811
const privateMethods =
912
require('internal/deps/acorn-plugins/acorn-private-methods/index');
1013
const classFields =
@@ -13,7 +16,30 @@ const numericSeparator =
1316
require('internal/deps/acorn-plugins/acorn-numeric-separator/index');
1417
const staticClassFeatures =
1518
require('internal/deps/acorn-plugins/acorn-static-class-features/index');
16-
const { tokTypes: tt, Parser: AcornParser } = acorn;
19+
20+
const { sendInspectorCommand } = require('internal/util/inspector');
21+
22+
const {
23+
ERR_INSPECTOR_NOT_AVAILABLE
24+
} = require('internal/errors').codes;
25+
26+
const {
27+
clearLine,
28+
cursorTo,
29+
moveCursor,
30+
} = require('readline');
31+
32+
const { inspect } = require('util');
33+
34+
const debug = require('internal/util/debuglog').debuglog('repl');
35+
36+
const inspectOptions = {
37+
depth: 1,
38+
colors: false,
39+
compact: true,
40+
breakLength: Infinity
41+
};
42+
const inspectedOptions = inspect(inspectOptions, { colors: false });
1743

1844
// If the error is that we've unexpectedly ended the input,
1945
// then let the user try to recover by adding more input.
@@ -91,7 +117,144 @@ function isRecoverableError(e, code) {
91117
}
92118
}
93119

120+
function setupPreview(repl, contextSymbol, bufferSymbol, active) {
121+
// Simple terminals can't handle previews.
122+
if (process.env.TERM === 'dumb' || !active) {
123+
return { showInputPreview() {}, clearPreview() {} };
124+
}
125+
126+
let preview = null;
127+
let lastPreview = '';
128+
129+
const clearPreview = () => {
130+
if (preview !== null) {
131+
moveCursor(repl.output, 0, 1);
132+
clearLine(repl.output);
133+
moveCursor(repl.output, 0, -1);
134+
lastPreview = preview;
135+
preview = null;
136+
}
137+
};
138+
139+
// This returns a code preview for arbitrary input code.
140+
function getPreviewInput(input, callback) {
141+
// For similar reasons as `defaultEval`, wrap expressions starting with a
142+
// curly brace with parenthesis.
143+
if (input.startsWith('{') && !input.endsWith(';')) {
144+
input = `(${input})`;
145+
}
146+
sendInspectorCommand((session) => {
147+
session.post('Runtime.evaluate', {
148+
expression: input,
149+
throwOnSideEffect: true,
150+
timeout: 333,
151+
contextId: repl[contextSymbol],
152+
}, (error, preview) => {
153+
if (error) {
154+
callback(error);
155+
return;
156+
}
157+
const { result } = preview;
158+
if (result.value !== undefined) {
159+
callback(null, inspect(result.value, inspectOptions));
160+
// Ignore EvalErrors, SyntaxErrors and ReferenceErrors. It is not clear
161+
// where they came from and if they are recoverable or not. Other errors
162+
// may be inspected.
163+
} else if (preview.exceptionDetails &&
164+
(result.className === 'EvalError' ||
165+
result.className === 'SyntaxError' ||
166+
result.className === 'ReferenceError')) {
167+
callback(null, null);
168+
} else if (result.objectId) {
169+
session.post('Runtime.callFunctionOn', {
170+
functionDeclaration: `(v) => util.inspect(v, ${inspectedOptions})`,
171+
objectId: result.objectId,
172+
arguments: [result]
173+
}, (error, preview) => {
174+
if (error) {
175+
callback(error);
176+
} else {
177+
callback(null, preview.result.value);
178+
}
179+
});
180+
} else {
181+
// Either not serializable or undefined.
182+
callback(null, result.unserializableValue || result.type);
183+
}
184+
});
185+
}, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE()));
186+
}
187+
188+
const showInputPreview = () => {
189+
// Prevent duplicated previews after a refresh.
190+
if (preview !== null) {
191+
return;
192+
}
193+
194+
const line = repl.line.trim();
195+
196+
// Do not preview if the command is buffered or if the line is empty.
197+
if (repl[bufferSymbol] || line === '') {
198+
return;
199+
}
200+
201+
getPreviewInput(line, (error, inspected) => {
202+
// Ignore the output if the value is identical to the current line and the
203+
// former preview is not identical to this preview.
204+
if ((line === inspected && lastPreview !== inspected) ||
205+
inspected === null) {
206+
return;
207+
}
208+
if (error) {
209+
debug('Error while generating preview', error);
210+
return;
211+
}
212+
// Do not preview `undefined` if colors are deactivated or explicitly
213+
// requested.
214+
if (inspected === 'undefined' &&
215+
(!repl.useColors || repl.ignoreUndefined)) {
216+
return;
217+
}
218+
219+
preview = inspected;
220+
221+
// Limit the output to maximum 250 characters. Otherwise it becomes a)
222+
// difficult to read and b) non terminal REPLs would visualize the whole
223+
// output.
224+
const maxColumns = MathMin(repl.columns, 250);
225+
226+
if (inspected.length > maxColumns) {
227+
inspected = `${inspected.slice(0, maxColumns - 6)}...`;
228+
}
229+
const lineBreakPos = inspected.indexOf('\n');
230+
if (lineBreakPos !== -1) {
231+
inspected = `${inspected.slice(0, lineBreakPos)}`;
232+
}
233+
const result = repl.useColors ?
234+
`\u001b[90m${inspected}\u001b[39m` :
235+
`// ${inspected}`;
236+
237+
repl.output.write(`\n${result}`);
238+
moveCursor(repl.output, 0, -1);
239+
cursorTo(repl.output, repl.cursor + repl._prompt.length);
240+
});
241+
};
242+
243+
// Refresh prints the whole screen again and the preview will be removed
244+
// during that procedure. Print the preview again. This also makes sure
245+
// the preview is always correct after resizing the terminal window.
246+
const tmpRefresh = repl._refreshLine.bind(repl);
247+
repl._refreshLine = () => {
248+
preview = null;
249+
tmpRefresh();
250+
showInputPreview();
251+
};
252+
253+
return { showInputPreview, clearPreview };
254+
}
255+
94256
module.exports = {
95257
isRecoverableError,
96-
kStandaloneREPL: Symbol('kStandaloneREPL')
258+
kStandaloneREPL: Symbol('kStandaloneREPL'),
259+
setupPreview
97260
};

lib/repl.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ const experimentalREPLAwait = require('internal/options').getOptionValue(
9898
);
9999
const {
100100
isRecoverableError,
101-
kStandaloneREPL
101+
kStandaloneREPL,
102+
setupPreview,
102103
} = require('internal/repl/utils');
103104
const {
104105
getOwnNonIndexProperties,
@@ -204,6 +205,9 @@ function REPLServer(prompt,
204205
}
205206
}
206207

208+
const preview = options.terminal &&
209+
(options.preview !== undefined ? !!options.preview : true);
210+
207211
this.inputStream = options.input;
208212
this.outputStream = options.output;
209213
this.useColors = !!options.useColors;
@@ -804,9 +808,20 @@ function REPLServer(prompt,
804808
}
805809
});
806810

811+
const {
812+
clearPreview,
813+
showInputPreview
814+
} = setupPreview(
815+
this,
816+
kContextId,
817+
kBufferedCommandSymbol,
818+
preview
819+
);
820+
807821
// Wrap readline tty to enable editor mode and pausing.
808822
const ttyWrite = self._ttyWrite.bind(self);
809823
self._ttyWrite = (d, key) => {
824+
clearPreview();
810825
key = key || {};
811826
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
812827
pausedBuffer.push(['key', [d, key]]);
@@ -819,6 +834,7 @@ function REPLServer(prompt,
819834
self.clearLine();
820835
}
821836
ttyWrite(d, key);
837+
showInputPreview();
822838
return;
823839
}
824840

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,28 +46,50 @@ ActionStream.prototype.readable = true;
4646
const ENTER = { name: 'enter' };
4747
const UP = { name: 'up' };
4848
const DOWN = { name: 'down' };
49+
const LEFT = { name: 'left' };
50+
const DELETE = { name: 'delete' };
4951

5052
const prompt = '> ';
5153

54+
const prev = process.features.inspector;
55+
5256
const tests = [
5357
{ // Creates few history to navigate for
5458
env: { NODE_REPL_HISTORY: defaultHistoryPath },
5559
test: [ 'let ab = 45', ENTER,
5660
'555 + 909', ENTER,
57-
'{key : {key2 :[] }}', ENTER],
61+
'{key : {key2 :[] }}', ENTER,
62+
'Array(100).fill(1).map((e, i) => i ** i)', LEFT, LEFT, DELETE,
63+
'2', ENTER],
5864
expected: [],
5965
clean: false
6066
},
6167
{
6268
env: { NODE_REPL_HISTORY: defaultHistoryPath },
63-
test: [UP, UP, UP, UP, DOWN, DOWN, DOWN],
69+
test: [UP, UP, UP, UP, UP, DOWN, DOWN, DOWN, DOWN],
6470
expected: [prompt,
71+
`${prompt}Array(100).fill(1).map((e, i) => i ** 2)`,
72+
prev && '\n// [ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, ' +
73+
'144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529,' +
74+
' 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, ' +
75+
'1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936,' +
76+
' 2025, 2116, 2209, ...',
6577
`${prompt}{key : {key2 :[] }}`,
78+
prev && '\n// { key: { key2: [] } }',
6679
`${prompt}555 + 909`,
80+
prev && '\n// 1464',
6781
`${prompt}let ab = 45`,
6882
`${prompt}555 + 909`,
83+
prev && '\n// 1464',
6984
`${prompt}{key : {key2 :[] }}`,
70-
prompt],
85+
prev && '\n// { key: { key2: [] } }',
86+
`${prompt}Array(100).fill(1).map((e, i) => i ** 2)`,
87+
prev && '\n// [ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, ' +
88+
'144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529,' +
89+
' 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, ' +
90+
'1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936,' +
91+
' 2025, 2116, 2209, ...',
92+
prompt].filter((e) => typeof e === 'string'),
7193
clean: true
7294
}
7395
];

test/parallel/test-repl-multiline.js

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,44 @@ const common = require('../common');
33
const ArrayStream = require('../common/arraystream');
44
const assert = require('assert');
55
const repl = require('repl');
6-
const inputStream = new ArrayStream();
7-
const outputStream = new ArrayStream();
8-
const input = ['const foo = {', '};', 'foo;'];
9-
let output = '';
6+
const input = ['const foo = {', '};', 'foo'];
107

11-
outputStream.write = (data) => { output += data.replace('\r', ''); };
8+
function run({ useColors }) {
9+
const inputStream = new ArrayStream();
10+
const outputStream = new ArrayStream();
11+
let output = '';
1212

13-
const r = repl.start({
14-
prompt: '',
15-
input: inputStream,
16-
output: outputStream,
17-
terminal: true,
18-
useColors: false
19-
});
13+
outputStream.write = (data) => { output += data.replace('\r', ''); };
2014

21-
r.on('exit', common.mustCall(() => {
22-
const actual = output.split('\n');
15+
const r = repl.start({
16+
prompt: '',
17+
input: inputStream,
18+
output: outputStream,
19+
terminal: true,
20+
useColors
21+
});
2322

24-
// Validate the output, which contains terminal escape codes.
25-
assert.strictEqual(actual.length, 6);
26-
assert.ok(actual[0].endsWith(input[0]));
27-
assert.ok(actual[1].includes('... '));
28-
assert.ok(actual[1].endsWith(input[1]));
29-
assert.strictEqual(actual[2], 'undefined');
30-
assert.ok(actual[3].endsWith(input[2]));
31-
assert.strictEqual(actual[4], '{}');
32-
// Ignore the last line, which is nothing but escape codes.
33-
}));
23+
r.on('exit', common.mustCall(() => {
24+
const actual = output.split('\n');
3425

35-
inputStream.run(input);
36-
r.close();
26+
// Validate the output, which contains terminal escape codes.
27+
assert.strictEqual(actual.length, 6 + process.features.inspector);
28+
assert.ok(actual[0].endsWith(input[0]));
29+
assert.ok(actual[1].includes('... '));
30+
assert.ok(actual[1].endsWith(input[1]));
31+
assert.ok(actual[2].includes('undefined'));
32+
assert.ok(actual[3].endsWith(input[2]));
33+
if (process.features.inspector) {
34+
assert.ok(actual[4].includes(actual[5]));
35+
assert.strictEqual(actual[4].includes('//'), !useColors);
36+
}
37+
assert.strictEqual(actual[4 + process.features.inspector], '{}');
38+
// Ignore the last line, which is nothing but escape codes.
39+
}));
40+
41+
inputStream.run(input);
42+
r.close();
43+
}
44+
45+
run({ useColors: true });
46+
run({ useColors: false });

0 commit comments

Comments
 (0)