node/lib/internal/repl/utils.js
Ruben Bridgewater ca9f12bf83
repl: fix preview of lines that exceed the terminal columns
This adds support for very long input lines to still display the
input preview correct.

PR-URL: https://github.com/nodejs/node/pull/31006
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Anto Aravinth <anto.aravinth.cse@gmail.com>
Reviewed-By: Rich Trott <rtrott@gmail.com>
2019-12-25 11:15:03 +01:00

401 lines
13 KiB
JavaScript

'use strict';
const {
MathMin,
Symbol,
} = 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 =
require('internal/deps/acorn-plugins/acorn-class-fields/index');
const numericSeparator =
require('internal/deps/acorn-plugins/acorn-numeric-separator/index');
const staticClassFeatures =
require('internal/deps/acorn-plugins/acorn-static-class-features/index');
const { sendInspectorCommand } = require('internal/util/inspector');
const {
ERR_INSPECTOR_NOT_AVAILABLE
} = require('internal/errors').codes;
const {
clearLine,
cursorTo,
moveCursor,
} = require('readline');
const {
commonPrefix
} = require('internal/readline/utils');
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.
// Note: `e` (the original exception) is not used by the current implementation,
// but may be needed in the future.
function isRecoverableError(e, code) {
// For similar reasons as `defaultEval`, wrap expressions starting with a
// curly brace with parenthesis. Note: only the open parenthesis is added
// here as the point is to test for potentially valid but incomplete
// expressions.
if (/^\s*\{/.test(code) && isRecoverableError(e, `(${code}`)) return true;
let recoverable = false;
// Determine if the point of any error raised is at the end of the input.
// There are two cases to consider:
//
// 1. Any error raised after we have encountered the 'eof' token.
// This prevents us from declaring partial tokens (like '2e') as
// recoverable.
//
// 2. Three cases where tokens can legally span lines. This is
// template, comment, and strings with a backslash at the end of
// the line, indicating a continuation. Note that we need to look
// for the specific errors of 'unterminated' kind (not, for example,
// a syntax error in a ${} expression in a template), and the only
// way to do that currently is to look at the message. Should Acorn
// change these messages in the future, this will lead to a test
// failure, indicating that this code needs to be updated.
//
const RecoverableParser = AcornParser
.extend(
privateMethods,
classFields,
numericSeparator,
staticClassFeatures,
(Parser) => {
return class extends Parser {
nextToken() {
super.nextToken();
if (this.type === tt.eof)
recoverable = true;
}
raise(pos, message) {
switch (message) {
case 'Unterminated template':
case 'Unterminated comment':
recoverable = true;
break;
case 'Unterminated string constant':
const token = this.input.slice(this.lastTokStart, this.pos);
// See https://www.ecma-international.org/ecma-262/#sec-line-terminators
if (/\\(?:\r\n?|\n|\u2028|\u2029)$/.test(token)) {
recoverable = true;
}
}
super.raise(pos, message);
}
};
}
);
// Try to parse the code with acorn. If the parse fails, ignore the acorn
// error and return the recoverable status.
try {
RecoverableParser.parse(code, { ecmaVersion: 11 });
// Odd case: the underlying JS engine (V8, Chakra) rejected this input
// but Acorn detected no issue. Presume that additional text won't
// address this issue.
return false;
} catch {
return recoverable;
}
}
function setupPreview(repl, contextSymbol, bufferSymbol, active) {
// Simple terminals can't handle previews.
if (process.env.TERM === 'dumb' || !active) {
return { showPreview() {}, clearPreview() {} };
}
let inputPreview = null;
let lastInputPreview = '';
let previewCompletionCounter = 0;
let completionPreview = null;
function getPreviewPos() {
const displayPos = repl._getDisplayPos(`${repl._prompt}${repl.line}`);
const cursorPos = repl._getCursorPos();
const rows = 1 + displayPos.rows - cursorPos.rows;
return { rows, cols: cursorPos.cols };
}
const clearPreview = () => {
if (inputPreview !== null) {
const { rows } = getPreviewPos();
moveCursor(repl.output, 0, rows);
clearLine(repl.output);
moveCursor(repl.output, 0, -rows);
lastInputPreview = inputPreview;
inputPreview = null;
}
if (completionPreview !== null) {
// Prevent cursor moves if not necessary!
const move = repl.line.length !== repl.cursor;
if (move) {
cursorTo(repl.output, repl._prompt.length + repl.line.length);
}
clearLine(repl.output, 1);
if (move) {
cursorTo(repl.output, repl._prompt.length + repl.cursor);
}
completionPreview = null;
}
};
function showCompletionPreview(line, insertPreview) {
previewCompletionCounter++;
const count = previewCompletionCounter;
repl.completer(line, (error, data) => {
// Tab completion might be async and the result might already be outdated.
if (count !== previewCompletionCounter) {
return;
}
if (error) {
debug('Error while generating completion preview', error);
return;
}
// Result and the text that was completed.
const [rawCompletions, completeOn] = data;
if (!rawCompletions || rawCompletions.length === 0) {
return;
}
// If there is a common prefix to all matches, then apply that portion.
const completions = rawCompletions.filter((e) => e);
const prefix = commonPrefix(completions);
// No common prefix found.
if (prefix.length <= completeOn.length) {
return;
}
const suffix = prefix.slice(completeOn.length);
const totalLength = repl.line.length +
repl._prompt.length +
suffix.length +
(repl.useColors ? 0 : 4);
// TODO(BridgeAR): Fix me. This should not be necessary. See similar
// comment in `showPreview()`.
if (totalLength > repl.columns) {
return;
}
if (insertPreview) {
repl._insertString(suffix);
return;
}
completionPreview = suffix;
const result = repl.useColors ?
`\u001b[90m${suffix}\u001b[39m` :
` // ${suffix}`;
if (repl.line.length !== repl.cursor) {
cursorTo(repl.output, repl._prompt.length + repl.line.length);
}
repl.output.write(result);
cursorTo(repl.output, repl._prompt.length + repl.cursor);
});
}
// This returns a code preview for arbitrary input code.
function getInputPreview(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 showPreview = () => {
// Prevent duplicated previews after a refresh.
if (inputPreview !== null) {
return;
}
const line = repl.line.trim();
// Do not preview in case the line only contains whitespace.
if (line === '') {
return;
}
// Add the autocompletion preview.
// TODO(BridgeAR): Trigger the input preview after the completion preview.
// That way it's possible to trigger the input prefix including the
// potential completion suffix. To do so, we also have to change the
// behavior of `enter` and `escape`:
// Enter should automatically add the suffix to the current line as long as
// escape was not pressed. We might even remove the preview in case any
// cursor movement is triggered.
if (typeof repl.completer === 'function') {
const insertPreview = false;
showCompletionPreview(repl.line, insertPreview);
}
// Do not preview if the command is buffered.
if (repl[bufferSymbol]) {
return;
}
getInputPreview(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 && lastInputPreview !== 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;
}
inputPreview = 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}`;
const { rows: previewRows, cols: cursorCols } = getPreviewPos();
if (previewRows !== 1)
moveCursor(repl.output, 0, previewRows - 1);
const { cols: resultCols } = repl._getDisplayPos(result);
repl.output.write(`\n${result}`);
moveCursor(repl.output, cursorCols - resultCols, -previewRows);
});
};
// -------------------------------------------------------------------------//
// Replace multiple interface functions. This is required to fully support //
// previews without changing readlines behavior. //
// -------------------------------------------------------------------------//
// 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 originalRefresh = repl._refreshLine.bind(repl);
repl._refreshLine = () => {
inputPreview = null;
originalRefresh();
showPreview();
};
let insertCompletionPreview = true;
// Insert the longest common suffix of the current input in case the user
// moves to the right while already being at the current input end.
const originalMoveCursor = repl._moveCursor.bind(repl);
repl._moveCursor = (dx) => {
const currentCursor = repl.cursor;
originalMoveCursor(dx);
if (currentCursor + dx > repl.line.length &&
typeof repl.completer === 'function' &&
insertCompletionPreview) {
const insertPreview = true;
showCompletionPreview(repl.line, insertPreview);
}
};
// This is the only function that interferes with the completion insertion.
// Monkey patch it to prevent inserting the completion when it shouldn't be.
const originalClearLine = repl.clearLine.bind(repl);
repl.clearLine = () => {
insertCompletionPreview = false;
originalClearLine();
insertCompletionPreview = true;
};
return { showPreview, clearPreview };
}
module.exports = {
isRecoverableError,
kStandaloneREPL: Symbol('kStandaloneREPL'),
setupPreview
};