Skip to content

Commit bf30154

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: nodejs#30811 Fixes: nodejs#20977 Reviewed-By: Yongsheng Zhang <[email protected]> Reviewed-By: Anto Aravinth <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
1 parent 8afab05 commit bf30154

File tree

7 files changed

+442
-71
lines changed

7 files changed

+442
-71
lines changed

doc/api/repl.md

+5
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,9 @@ with REPL instances programmatically.
520520
<!-- YAML
521521
added: v0.1.91
522522
changes:
523+
- version: REPLACEME
524+
pr-url: https://github.com/nodejs/node/pull/30811
525+
description: The `preview` option is now available.
523526
- version: v12.0.0
524527
pr-url: https://github.com/nodejs/node/pull/26518
525528
description: The `terminal` option now follows the default description in
@@ -572,6 +575,8 @@ changes:
572575
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
573576
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
574577
together with a custom `eval` function. **Default:** `false`.
578+
* `preview` {boolean} Defines if the repl prints output previews or not.
579+
**Default:** `true`. Always `false` in case `terminal` is falsy.
575580
* Returns: {repl.REPLServer}
576581

577582
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
@@ -106,7 +106,8 @@ const experimentalREPLAwait = require('internal/options').getOptionValue(
106106
);
107107
const {
108108
isRecoverableError,
109-
kStandaloneREPL
109+
kStandaloneREPL,
110+
setupPreview,
110111
} = require('internal/repl/utils');
111112
const {
112113
getOwnNonIndexProperties,
@@ -215,6 +216,9 @@ function REPLServer(prompt,
215216
}
216217
}
217218

219+
const preview = options.terminal &&
220+
(options.preview !== undefined ? !!options.preview : true);
221+
218222
this.inputStream = options.input;
219223
this.outputStream = options.output;
220224
this.useColors = !!options.useColors;
@@ -815,9 +819,20 @@ function REPLServer(prompt,
815819
}
816820
});
817821

822+
const {
823+
clearPreview,
824+
showInputPreview
825+
} = setupPreview(
826+
this,
827+
kContextId,
828+
kBufferedCommandSymbol,
829+
preview
830+
);
831+
818832
// Wrap readline tty to enable editor mode and pausing.
819833
const ttyWrite = self._ttyWrite.bind(self);
820834
self._ttyWrite = (d, key) => {
835+
clearPreview();
821836
key = key || {};
822837
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
823838
pausedBuffer.push(['key', [d, key]]);
@@ -830,6 +845,7 @@ function REPLServer(prompt,
830845
self.clearLine();
831846
}
832847
ttyWrite(d, key);
848+
showInputPreview();
833849
return;
834850
}
835851

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)