Skip to content

Commit e551c16

Browse files
BridgeARtargos
authored andcommitted
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). PR-URL: #30811 Fixes: #20977 Reviewed-By: Yongsheng Zhang <[email protected]> Reviewed-By: Anto Aravinth <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
1 parent daca078 commit e551c16

7 files changed

+442
-71
lines changed

doc/api/repl.md

+5
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ 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 now available.
513516
- version: v12.0.0
514517
pr-url: https://github.com/nodejs/node/pull/26518
515518
description: The `terminal` option now follows the default description in
@@ -562,6 +565,8 @@ changes:
562565
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
563566
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
564567
together with a custom `eval` function. **Default:** `false`.
568+
* `preview` {boolean} Defines if the repl prints output previews or not.
569+
**Default:** `true`. Always `false` in case `terminal` is falsy.
565570
* Returns: {repl.REPLServer}
566571

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

lib/internal/repl/utils.js

+165-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use strict';
22

33
const {
4+
MathMin,
45
Symbol,
56
} = primordials;
67

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

1843
// If the error is that we've unexpectedly ended the input,
1944
// then let the user try to recover by adding more input.
@@ -91,7 +116,144 @@ function isRecoverableError(e, code) {
91116
}
92117
}
93118

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

lib/repl.js

+17-1
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

+25-3
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

+36-26
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)