diff --git a/src/io.js b/src/io.js index 807cd0e..42ed74d 100644 --- a/src/io.js +++ b/src/io.js @@ -9,10 +9,11 @@ class IO { this.stdin = stdin; this.stdout = stdout; - this.buffer = ''; + this.buffer = ''; // used for capturing the current statement this.cursor = 0; this.prefix = ''; this.suffix = ''; + this.multilineBuffer = ''; // for buffering the multiline statements this._paused = false; this.transformBuffer = transformBuffer; @@ -134,7 +135,19 @@ class IO { this.buffer = ''; this.cursor = 0; this.history.unshift(b); - this.stdout.write(`${await onLine(b)}\n`); + this.multilineBuffer += b; + // always buffer the line so that when we encounter multi-line + // statements we can execute them as needed. + const code = this.multilineBuffer; + const result = await onLine(code); + // online returns a Symbol when it sees a multi-line statement + if (IO.kNeedsAnotherLine !== result) { + this.stdout.write(`${result}\n`); + this.multilineBuffer = ''; + this.setPrefix('> '); + } else { + this.setPrefix('... '); + } this.unpause(); } else { this.stdout.write('\n'); @@ -298,4 +311,7 @@ class IO { } } +// Symbol to notify that IO needs an another line +IO.kNeedsAnotherLine = Symbol('IO.kNeedsAnotherLine'); + module.exports = IO; diff --git a/src/recoverable.js b/src/recoverable.js new file mode 100644 index 0000000..2ed0d2b --- /dev/null +++ b/src/recoverable.js @@ -0,0 +1,79 @@ +'use strict'; + +const acorn = require('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': + case 'Unexpected end of input': + 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 (e) { + return recoverable; + } +} + +module.exports = isRecoverableError + diff --git a/src/repl.js b/src/repl.js index 460b316..7a1cc4c 100644 --- a/src/repl.js +++ b/src/repl.js @@ -6,6 +6,7 @@ const highlight = require('./highlight'); const { processTopLevelAwait } = require('./await'); const { Runtime, mainContextIdPromise } = require('./inspector'); const { strEscape, isIdentifier } = require('./util'); +const isRecoverableError = require('./recoverable'); // TODO(devsnek): make more robust Error.prepareStackTrace = (err, frames) => { @@ -105,21 +106,29 @@ Prototype REPL - https://github.com/nodejs/repl`, const evaluateResult = await this.eval(line, awaited); if (evaluateResult.exceptionDetails) { + // lets try for recovering + const result = isRecoverableError(evaluateResult.exceptionDetails.exception, line); + if (result) { + return IO.kNeedsAnotherLine; + } else { + // we tried our best - throw error + await this.callFunctionOn( + (err) => { + global.REPL.lastError = err; + }, + evaluateResult.exceptionDetails.exception, + ); + return inspect(global.REPL.lastError); + } + } else { await this.callFunctionOn( - (err) => { - global.REPL.lastError = err; + (result) => { + global.REPL.last = result; }, - evaluateResult.exceptionDetails.exception, + evaluateResult.result, ); - return inspect(global.REPL.lastError); + return inspect(global.REPL.last); } - await this.callFunctionOn( - (result) => { - global.REPL.last = result; - }, - evaluateResult.result, - ); - return inspect(global.REPL.last); } async onAutocomplete(buffer) {