Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repl: support previews by eager evaluating input #30811

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/api/repl.md
Expand Up @@ -510,6 +510,10 @@ 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 available from now on. The input
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved
generates output previews from now on.
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved
- 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 @@ -562,6 +566,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
169 changes: 166 additions & 3 deletions lib/internal/repl/utils.js
Expand Up @@ -4,7 +4,10 @@ const {
Symbol,
} = primordials;

const acorn = require('internal/deps/acorn/acorn/dist/acorn');
const { MathMin } = primordials;
BridgeAR marked this conversation as resolved.
Show resolved Hide resolved

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 +16,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 });
ZYSzys marked this conversation as resolved.
Show resolved Hide resolved

// 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 +117,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', {
antsmartian marked this conversation as resolved.
Show resolved Hide resolved
expression: input,
throwOnSideEffect: true,
timeout: 333,
targos marked this conversation as resolved.
Show resolved Hide resolved
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);
antsmartian marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Up @@ -98,7 +98,8 @@ const experimentalREPLAwait = require('internal/options').getOptionValue(
);
const {
isRecoverableError,
kStandaloneREPL
kStandaloneREPL,
setupPreview,
} = require('internal/repl/utils');
const {
getOwnNonIndexProperties,
Expand Down Expand Up @@ -204,6 +205,9 @@ function REPLServer(prompt,
}
}

const preview = options.terminal &&
ZYSzys marked this conversation as resolved.
Show resolved Hide resolved
(options.preview !== undefined ? !!options.preview : true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this still prevent previews from being enabled if terminal is false-y? Why not only take .terminal into account if options.preview === undefined?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is correct. I would have liked to do that but the current proposal does not support previews for non-terminals yet. That requires additional work and I would rather do that in a follow-up PR.


this.inputStream = options.input;
this.outputStream = options.output;
this.useColors = !!options.useColors;
Expand Down Expand Up @@ -804,9 +808,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 @@ -819,6 +834,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
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)`,
antsmartian marked this conversation as resolved.
Show resolved Hide resolved
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
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 });