diff --git a/lib/internal/repl/recoverable.js b/lib/internal/repl/recoverable.js new file mode 100644 index 00000000000000..465d77451a5b82 --- /dev/null +++ b/lib/internal/repl/recoverable.js @@ -0,0 +1,79 @@ +'use strict'; + +const acorn = require('internal/deps/acorn/dist/acorn'); +const { tokTypes: tt } = acorn; + +// 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 implemention, +// but may be needed in the future. +function isRecoverableError(e, code) { + let recoverable = false; + + // Determine if the point of the 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. + // + acorn.plugins.replRecoverable = (parser) => { + parser.extend('nextToken', (nextToken) => { + return function() { + Reflect.apply(nextToken, this, []); + + if (this.type === tt.eof) recoverable = true; + }; + }); + + parser.extend('raise', (raise) => { + return function(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 + recoverable = /\\(?:\r\n?|\n|\u2028|\u2029)$/.test(token); + } + + Reflect.apply(raise, this, [pos, message]); + }; + }); + }; + + // 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; + + // Try to parse the code with acorn. If the parse fails, ignore the acorn + // error and return the recoverable status. + try { + acorn.parse(code, { plugins: { replRecoverable: true } }); + + // 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; + } +} + +module.exports = { + isRecoverableError +}; diff --git a/lib/repl.js b/lib/repl.js index 92c90de7bb1646..4a01595ce1b72b 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -73,6 +73,7 @@ const { } = require('internal/errors').codes; const { sendInspectorCommand } = require('internal/util/inspector'); const { experimentalREPLAwait } = process.binding('config'); +const { isRecoverableError } = require('internal/repl/recoverable'); // Lazy-loaded. let processTopLevelAwait; @@ -227,7 +228,8 @@ function REPLServer(prompt, // It's confusing for `{ a : 1 }` to be interpreted as a block // statement rather than an object literal. So, we first try // to wrap it in parentheses, so that it will be interpreted as - // an expression. + // an expression. Note that if the above condition changes, + // lib/internal/repl/recoverable.js needs to be changed to match. code = `(${code.trim()})\n`; wrappedCmd = true; } @@ -1505,76 +1507,6 @@ function regexpEscape(s) { return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } -// If the error is that we've unexpectedly ended the input, -// then let the user try to recover by adding more input. -function isRecoverableError(e, code) { - if (e && e.name === 'SyntaxError') { - var message = e.message; - if (message === 'Unterminated template literal' || - message === 'Unexpected end of input') { - return true; - } - - if (message === 'missing ) after argument list') { - const frames = e.stack.split(/\r?\n/); - const pos = frames.findIndex((f) => f.match(/^\s*\^+$/)); - return pos > 0 && frames[pos - 1].length === frames[pos].length; - } - - if (message === 'Invalid or unexpected token') - return isCodeRecoverable(code); - } - return false; -} - -// Check whether a code snippet should be forced to fail in the REPL. -function isCodeRecoverable(code) { - var current, previous, stringLiteral; - var isBlockComment = false; - var isSingleComment = false; - var isRegExpLiteral = false; - var lastChar = code.charAt(code.length - 2); - var prevTokenChar = null; - - for (var i = 0; i < code.length; i++) { - previous = current; - current = code[i]; - - if (previous === '\\' && (stringLiteral || isRegExpLiteral)) { - current = null; - } else if (stringLiteral) { - if (stringLiteral === current) { - stringLiteral = null; - } - } else if (isRegExpLiteral && current === '/') { - isRegExpLiteral = false; - } else if (isBlockComment && previous === '*' && current === '/') { - isBlockComment = false; - } else if (isSingleComment && current === '\n') { - isSingleComment = false; - } else if (!isBlockComment && !isRegExpLiteral && !isSingleComment) { - if (current === '/' && previous === '/') { - isSingleComment = true; - } else if (previous === '/') { - if (current === '*') { - isBlockComment = true; - // Distinguish between a division operator and the start of a regex - // by examining the non-whitespace character that precedes the / - } else if ([null, '(', '[', '{', '}', ';'].includes(prevTokenChar)) { - isRegExpLiteral = true; - } - } else { - if (current.trim()) prevTokenChar = current; - if (current === '\'' || current === '"') { - stringLiteral = current; - } - } - } - } - - return stringLiteral ? lastChar === '\\' : isBlockComment; -} - function Recoverable(err) { this.err = err; } diff --git a/node.gyp b/node.gyp index 7460d73c0b578a..682111051cb800 100644 --- a/node.gyp +++ b/node.gyp @@ -147,6 +147,7 @@ 'lib/internal/readline.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js', + 'lib/internal/repl/recoverable.js', 'lib/internal/socket_list.js', 'lib/internal/test/binding.js', 'lib/internal/test/heap.js', diff --git a/test/parallel/test-repl.js b/test/parallel/test-repl.js index 35cd3e11afab53..8cb4b686b85e40 100644 --- a/test/parallel/test-repl.js +++ b/test/parallel/test-repl.js @@ -162,13 +162,11 @@ const errorTests = [ // Template expressions { send: '`io.js ${"1.0"', - expect: [ - kSource, - kArrow, - '', - /^SyntaxError: /, - '' - ] + expect: '... ' + }, + { + send: '+ ".2"}`', + expect: '\'io.js 1.0.2\'' }, { send: '`io.js ${', @@ -315,6 +313,15 @@ const errorTests = [ send: '1 }', expect: '{ a: 1 }' }, + // Multiline string-keyed object (e.g. JSON) + { + send: '{ "a": ', + expect: '... ' + }, + { + send: '1 }', + expect: '{ a: 1 }' + }, // Multiline anonymous function with comment { send: '(function() {',