Skip to content

Commit

Permalink
repl: support previews by eager evaluating input
Browse files Browse the repository at this point in the history
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 <zyszys98@gmail.com>
Reviewed-By: Anto Aravinth <anto.aravinth.cse@gmail.com>
Reviewed-By: Michaël Zasso <targos@protonmail.com>
  • Loading branch information
BridgeAR authored and targos committed Apr 28, 2020
1 parent d609881 commit b14440f
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 71 deletions.
5 changes: 5 additions & 0 deletions doc/api/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ with REPL instances programmatically.
<!-- YAML
added: v0.1.91
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30811
description: The `preview` option is now available.
- version: v12.0.0
pr-url: https://github.com/nodejs/node/pull/26518
description: The `terminal` option now follows the default description in
Expand Down Expand Up @@ -572,6 +575,8 @@ changes:
* `breakEvalOnSigint` {boolean} Stop evaluating the current piece of code when
`SIGINT` is received, such as when `Ctrl+C` is pressed. This cannot be used
together with a custom `eval` function. **Default:** `false`.
* `preview` {boolean} Defines if the repl prints output previews or not.
**Default:** `true`. Always `false` in case `terminal` is falsy.
* Returns: {repl.REPLServer}

The `repl.start()` method creates and starts a [`repl.REPLServer`][] instance.
Expand Down
168 changes: 165 additions & 3 deletions lib/internal/repl/utils.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use strict';

const {
MathMin,
Symbol,
} = primordials;

const acorn = require('internal/deps/acorn/acorn/dist/acorn');
const { tokTypes: tt, Parser: AcornParser } =
require('internal/deps/acorn/acorn/dist/acorn');
const privateMethods =
require('internal/deps/acorn-plugins/acorn-private-methods/index');
const classFields =
Expand All @@ -13,7 +15,30 @@ const numericSeparator =
require('internal/deps/acorn-plugins/acorn-numeric-separator/index');
const staticClassFeatures =
require('internal/deps/acorn-plugins/acorn-static-class-features/index');
const { tokTypes: tt, Parser: AcornParser } = acorn;

const { sendInspectorCommand } = require('internal/util/inspector');

const {
ERR_INSPECTOR_NOT_AVAILABLE
} = require('internal/errors').codes;

const {
clearLine,
cursorTo,
moveCursor,
} = require('readline');

const { inspect } = require('util');

const debug = require('internal/util/debuglog').debuglog('repl');

const inspectOptions = {
depth: 1,
colors: false,
compact: true,
breakLength: Infinity
};
const inspectedOptions = inspect(inspectOptions, { colors: false });

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

function setupPreview(repl, contextSymbol, bufferSymbol, active) {
// Simple terminals can't handle previews.
if (process.env.TERM === 'dumb' || !active) {
return { showInputPreview() {}, clearPreview() {} };
}

let preview = null;
let lastPreview = '';

const clearPreview = () => {
if (preview !== null) {
moveCursor(repl.output, 0, 1);
clearLine(repl.output);
moveCursor(repl.output, 0, -1);
lastPreview = preview;
preview = null;
}
};

// This returns a code preview for arbitrary input code.
function getPreviewInput(input, callback) {
// For similar reasons as `defaultEval`, wrap expressions starting with a
// curly brace with parenthesis.
if (input.startsWith('{') && !input.endsWith(';')) {
input = `(${input})`;
}
sendInspectorCommand((session) => {
session.post('Runtime.evaluate', {
expression: input,
throwOnSideEffect: true,
timeout: 333,
contextId: repl[contextSymbol],
}, (error, preview) => {
if (error) {
callback(error);
return;
}
const { result } = preview;
if (result.value !== undefined) {
callback(null, inspect(result.value, inspectOptions));
// Ignore EvalErrors, SyntaxErrors and ReferenceErrors. It is not clear
// where they came from and if they are recoverable or not. Other errors
// may be inspected.
} else if (preview.exceptionDetails &&
(result.className === 'EvalError' ||
result.className === 'SyntaxError' ||
result.className === 'ReferenceError')) {
callback(null, null);
} else if (result.objectId) {
session.post('Runtime.callFunctionOn', {
functionDeclaration: `(v) => util.inspect(v, ${inspectedOptions})`,
objectId: result.objectId,
arguments: [result]
}, (error, preview) => {
if (error) {
callback(error);
} else {
callback(null, preview.result.value);
}
});
} else {
// Either not serializable or undefined.
callback(null, result.unserializableValue || result.type);
}
});
}, () => callback(new ERR_INSPECTOR_NOT_AVAILABLE()));
}

const showInputPreview = () => {
// Prevent duplicated previews after a refresh.
if (preview !== null) {
return;
}

const line = repl.line.trim();

// Do not preview if the command is buffered or if the line is empty.
if (repl[bufferSymbol] || line === '') {
return;
}

getPreviewInput(line, (error, inspected) => {
// Ignore the output if the value is identical to the current line and the
// former preview is not identical to this preview.
if ((line === inspected && lastPreview !== inspected) ||
inspected === null) {
return;
}
if (error) {
debug('Error while generating preview', error);
return;
}
// Do not preview `undefined` if colors are deactivated or explicitly
// requested.
if (inspected === 'undefined' &&
(!repl.useColors || repl.ignoreUndefined)) {
return;
}

preview = inspected;

// Limit the output to maximum 250 characters. Otherwise it becomes a)
// difficult to read and b) non terminal REPLs would visualize the whole
// output.
const maxColumns = MathMin(repl.columns, 250);

if (inspected.length > maxColumns) {
inspected = `${inspected.slice(0, maxColumns - 6)}...`;
}
const lineBreakPos = inspected.indexOf('\n');
if (lineBreakPos !== -1) {
inspected = `${inspected.slice(0, lineBreakPos)}`;
}
const result = repl.useColors ?
`\u001b[90m${inspected}\u001b[39m` :
`// ${inspected}`;

repl.output.write(`\n${result}`);
moveCursor(repl.output, 0, -1);
cursorTo(repl.output, repl.cursor + repl._prompt.length);
});
};

// Refresh prints the whole screen again and the preview will be removed
// during that procedure. Print the preview again. This also makes sure
// the preview is always correct after resizing the terminal window.
const tmpRefresh = repl._refreshLine.bind(repl);
repl._refreshLine = () => {
preview = null;
tmpRefresh();
showInputPreview();
};

return { showInputPreview, clearPreview };
}

module.exports = {
isRecoverableError,
kStandaloneREPL: Symbol('kStandaloneREPL')
kStandaloneREPL: Symbol('kStandaloneREPL'),
setupPreview
};
18 changes: 17 additions & 1 deletion lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ const experimentalREPLAwait = require('internal/options').getOptionValue(
);
const {
isRecoverableError,
kStandaloneREPL
kStandaloneREPL,
setupPreview,
} = require('internal/repl/utils');
const {
getOwnNonIndexProperties,
Expand Down Expand Up @@ -215,6 +216,9 @@ function REPLServer(prompt,
}
}

const preview = options.terminal &&
(options.preview !== undefined ? !!options.preview : true);

this.inputStream = options.input;
this.outputStream = options.output;
this.useColors = !!options.useColors;
Expand Down Expand Up @@ -819,9 +823,20 @@ function REPLServer(prompt,
}
});

const {
clearPreview,
showInputPreview
} = setupPreview(
this,
kContextId,
kBufferedCommandSymbol,
preview
);

// Wrap readline tty to enable editor mode and pausing.
const ttyWrite = self._ttyWrite.bind(self);
self._ttyWrite = (d, key) => {
clearPreview();
key = key || {};
if (paused && !(self.breakEvalOnSigint && key.ctrl && key.name === 'c')) {
pausedBuffer.push(['key', [d, key]]);
Expand All @@ -834,6 +849,7 @@ function REPLServer(prompt,
self.clearLine();
}
ttyWrite(d, key);
showInputPreview();
return;
}

Expand Down
28 changes: 25 additions & 3 deletions test/parallel/test-repl-history-navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,50 @@ ActionStream.prototype.readable = true;
const ENTER = { name: 'enter' };
const UP = { name: 'up' };
const DOWN = { name: 'down' };
const LEFT = { name: 'left' };
const DELETE = { name: 'delete' };

const prompt = '> ';

const prev = process.features.inspector;

const tests = [
{ // Creates few history to navigate for
env: { NODE_REPL_HISTORY: defaultHistoryPath },
test: [ 'let ab = 45', ENTER,
'555 + 909', ENTER,
'{key : {key2 :[] }}', ENTER],
'{key : {key2 :[] }}', ENTER,
'Array(100).fill(1).map((e, i) => i ** i)', LEFT, LEFT, DELETE,
'2', ENTER],
expected: [],
clean: false
},
{
env: { NODE_REPL_HISTORY: defaultHistoryPath },
test: [UP, UP, UP, UP, DOWN, DOWN, DOWN],
test: [UP, UP, UP, UP, UP, DOWN, DOWN, DOWN, DOWN],
expected: [prompt,
`${prompt}Array(100).fill(1).map((e, i) => i ** 2)`,
prev && '\n// [ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, ' +
'144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529,' +
' 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, ' +
'1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936,' +
' 2025, 2116, 2209, ...',
`${prompt}{key : {key2 :[] }}`,
prev && '\n// { key: { key2: [] } }',
`${prompt}555 + 909`,
prev && '\n// 1464',
`${prompt}let ab = 45`,
`${prompt}555 + 909`,
prev && '\n// 1464',
`${prompt}{key : {key2 :[] }}`,
prompt],
prev && '\n// { key: { key2: [] } }',
`${prompt}Array(100).fill(1).map((e, i) => i ** 2)`,
prev && '\n// [ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, ' +
'144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529,' +
' 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, ' +
'1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936,' +
' 2025, 2116, 2209, ...',
prompt].filter((e) => typeof e === 'string'),
clean: true
}
];
Expand Down
62 changes: 36 additions & 26 deletions test/parallel/test-repl-multiline.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,44 @@ const common = require('../common');
const ArrayStream = require('../common/arraystream');
const assert = require('assert');
const repl = require('repl');
const inputStream = new ArrayStream();
const outputStream = new ArrayStream();
const input = ['const foo = {', '};', 'foo;'];
let output = '';
const input = ['const foo = {', '};', 'foo'];

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

const r = repl.start({
prompt: '',
input: inputStream,
output: outputStream,
terminal: true,
useColors: false
});
outputStream.write = (data) => { output += data.replace('\r', ''); };

r.on('exit', common.mustCall(() => {
const actual = output.split('\n');
const r = repl.start({
prompt: '',
input: inputStream,
output: outputStream,
terminal: true,
useColors
});

// Validate the output, which contains terminal escape codes.
assert.strictEqual(actual.length, 6);
assert.ok(actual[0].endsWith(input[0]));
assert.ok(actual[1].includes('... '));
assert.ok(actual[1].endsWith(input[1]));
assert.strictEqual(actual[2], 'undefined');
assert.ok(actual[3].endsWith(input[2]));
assert.strictEqual(actual[4], '{}');
// Ignore the last line, which is nothing but escape codes.
}));
r.on('exit', common.mustCall(() => {
const actual = output.split('\n');

inputStream.run(input);
r.close();
// Validate the output, which contains terminal escape codes.
assert.strictEqual(actual.length, 6 + process.features.inspector);
assert.ok(actual[0].endsWith(input[0]));
assert.ok(actual[1].includes('... '));
assert.ok(actual[1].endsWith(input[1]));
assert.ok(actual[2].includes('undefined'));
assert.ok(actual[3].endsWith(input[2]));
if (process.features.inspector) {
assert.ok(actual[4].includes(actual[5]));
assert.strictEqual(actual[4].includes('//'), !useColors);
}
assert.strictEqual(actual[4 + process.features.inspector], '{}');
// Ignore the last line, which is nothing but escape codes.
}));

inputStream.run(input);
r.close();
}

run({ useColors: true });
run({ useColors: false });
Loading

0 comments on commit b14440f

Please sign in to comment.