Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/api/repl.md
Original file line number Diff line number Diff line change
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
generates output previews from now on.
- 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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ const {
Symbol,
} = primordials;

const acorn = require('internal/deps/acorn/acorn/dist/acorn');
const { MathMin } = primordials;

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 });

// 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', {
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 @@ -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 &&
(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
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