Skip to content

Commit e4f3468

Browse files
rubystargos
authored andcommitted
repl: support mult-line string-keyed objects
isRecoverableError is completely reimplemented using acorn and an acorn plugin that examines the state of the parser at the time of the error to determine if the code could be completed on a subsequent line. PR-URL: #21805 Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Tiancheng "Timothy" Gu <[email protected]> Reviewed-By: John-David Dalton <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent 28870a4 commit e4f3468

File tree

4 files changed

+97
-78
lines changed

4 files changed

+97
-78
lines changed

lib/internal/repl/recoverable.js

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
3+
const acorn = require('internal/deps/acorn/dist/acorn');
4+
const { tokTypes: tt } = acorn;
5+
6+
// If the error is that we've unexpectedly ended the input,
7+
// then let the user try to recover by adding more input.
8+
// Note: `e` (the original exception) is not used by the current implemention,
9+
// but may be needed in the future.
10+
function isRecoverableError(e, code) {
11+
let recoverable = false;
12+
13+
// Determine if the point of the any error raised is at the end of the input.
14+
// There are two cases to consider:
15+
//
16+
// 1. Any error raised after we have encountered the 'eof' token.
17+
// This prevents us from declaring partial tokens (like '2e') as
18+
// recoverable.
19+
//
20+
// 2. Three cases where tokens can legally span lines. This is
21+
// template, comment, and strings with a backslash at the end of
22+
// the line, indicating a continuation. Note that we need to look
23+
// for the specific errors of 'unterminated' kind (not, for example,
24+
// a syntax error in a ${} expression in a template), and the only
25+
// way to do that currently is to look at the message. Should Acorn
26+
// change these messages in the future, this will lead to a test
27+
// failure, indicating that this code needs to be updated.
28+
//
29+
acorn.plugins.replRecoverable = (parser) => {
30+
parser.extend('nextToken', (nextToken) => {
31+
return function() {
32+
Reflect.apply(nextToken, this, []);
33+
34+
if (this.type === tt.eof) recoverable = true;
35+
};
36+
});
37+
38+
parser.extend('raise', (raise) => {
39+
return function(pos, message) {
40+
switch (message) {
41+
case 'Unterminated template':
42+
case 'Unterminated comment':
43+
recoverable = true;
44+
break;
45+
46+
case 'Unterminated string constant':
47+
const token = this.input.slice(this.lastTokStart, this.pos);
48+
// see https://www.ecma-international.org/ecma-262/#sec-line-terminators
49+
recoverable = /\\(?:\r\n?|\n|\u2028|\u2029)$/.test(token);
50+
}
51+
52+
Reflect.apply(raise, this, [pos, message]);
53+
};
54+
});
55+
};
56+
57+
// For similar reasons as `defaultEval`, wrap expressions starting with a
58+
// curly brace with parenthesis. Note: only the open parenthesis is added
59+
// here as the point is to test for potentially valid but incomplete
60+
// expressions.
61+
if (/^\s*\{/.test(code) && isRecoverableError(e, `(${code}`)) return true;
62+
63+
// Try to parse the code with acorn. If the parse fails, ignore the acorn
64+
// error and return the recoverable status.
65+
try {
66+
acorn.parse(code, { plugins: { replRecoverable: true } });
67+
68+
// Odd case: the underlying JS engine (V8, Chakra) rejected this input
69+
// but Acorn detected no issue. Presume that additional text won't
70+
// address this issue.
71+
return false;
72+
} catch {
73+
return recoverable;
74+
}
75+
}
76+
77+
module.exports = {
78+
isRecoverableError
79+
};

lib/repl.js

+3-71
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const {
7373
} = require('internal/errors').codes;
7474
const { sendInspectorCommand } = require('internal/util/inspector');
7575
const { experimentalREPLAwait } = process.binding('config');
76+
const { isRecoverableError } = require('internal/repl/recoverable');
7677

7778
// Lazy-loaded.
7879
let processTopLevelAwait;
@@ -227,7 +228,8 @@ function REPLServer(prompt,
227228
// It's confusing for `{ a : 1 }` to be interpreted as a block
228229
// statement rather than an object literal. So, we first try
229230
// to wrap it in parentheses, so that it will be interpreted as
230-
// an expression.
231+
// an expression. Note that if the above condition changes,
232+
// lib/internal/repl/recoverable.js needs to be changed to match.
231233
code = `(${code.trim()})\n`;
232234
wrappedCmd = true;
233235
}
@@ -1505,76 +1507,6 @@ function regexpEscape(s) {
15051507
return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
15061508
}
15071509

1508-
// If the error is that we've unexpectedly ended the input,
1509-
// then let the user try to recover by adding more input.
1510-
function isRecoverableError(e, code) {
1511-
if (e && e.name === 'SyntaxError') {
1512-
var message = e.message;
1513-
if (message === 'Unterminated template literal' ||
1514-
message === 'Unexpected end of input') {
1515-
return true;
1516-
}
1517-
1518-
if (message === 'missing ) after argument list') {
1519-
const frames = e.stack.split(/\r?\n/);
1520-
const pos = frames.findIndex((f) => f.match(/^\s*\^+$/));
1521-
return pos > 0 && frames[pos - 1].length === frames[pos].length;
1522-
}
1523-
1524-
if (message === 'Invalid or unexpected token')
1525-
return isCodeRecoverable(code);
1526-
}
1527-
return false;
1528-
}
1529-
1530-
// Check whether a code snippet should be forced to fail in the REPL.
1531-
function isCodeRecoverable(code) {
1532-
var current, previous, stringLiteral;
1533-
var isBlockComment = false;
1534-
var isSingleComment = false;
1535-
var isRegExpLiteral = false;
1536-
var lastChar = code.charAt(code.length - 2);
1537-
var prevTokenChar = null;
1538-
1539-
for (var i = 0; i < code.length; i++) {
1540-
previous = current;
1541-
current = code[i];
1542-
1543-
if (previous === '\\' && (stringLiteral || isRegExpLiteral)) {
1544-
current = null;
1545-
} else if (stringLiteral) {
1546-
if (stringLiteral === current) {
1547-
stringLiteral = null;
1548-
}
1549-
} else if (isRegExpLiteral && current === '/') {
1550-
isRegExpLiteral = false;
1551-
} else if (isBlockComment && previous === '*' && current === '/') {
1552-
isBlockComment = false;
1553-
} else if (isSingleComment && current === '\n') {
1554-
isSingleComment = false;
1555-
} else if (!isBlockComment && !isRegExpLiteral && !isSingleComment) {
1556-
if (current === '/' && previous === '/') {
1557-
isSingleComment = true;
1558-
} else if (previous === '/') {
1559-
if (current === '*') {
1560-
isBlockComment = true;
1561-
// Distinguish between a division operator and the start of a regex
1562-
// by examining the non-whitespace character that precedes the /
1563-
} else if ([null, '(', '[', '{', '}', ';'].includes(prevTokenChar)) {
1564-
isRegExpLiteral = true;
1565-
}
1566-
} else {
1567-
if (current.trim()) prevTokenChar = current;
1568-
if (current === '\'' || current === '"') {
1569-
stringLiteral = current;
1570-
}
1571-
}
1572-
}
1573-
}
1574-
1575-
return stringLiteral ? lastChar === '\\' : isBlockComment;
1576-
}
1577-
15781510
function Recoverable(err) {
15791511
this.err = err;
15801512
}

node.gyp

+1
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
'lib/internal/readline.js',
147147
'lib/internal/repl.js',
148148
'lib/internal/repl/await.js',
149+
'lib/internal/repl/recoverable.js',
149150
'lib/internal/socket_list.js',
150151
'lib/internal/test/binding.js',
151152
'lib/internal/test/heap.js',

test/parallel/test-repl.js

+14-7
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,11 @@ const errorTests = [
162162
// Template expressions
163163
{
164164
send: '`io.js ${"1.0"',
165-
expect: [
166-
kSource,
167-
kArrow,
168-
'',
169-
/^SyntaxError: /,
170-
''
171-
]
165+
expect: '... '
166+
},
167+
{
168+
send: '+ ".2"}`',
169+
expect: '\'io.js 1.0.2\''
172170
},
173171
{
174172
send: '`io.js ${',
@@ -315,6 +313,15 @@ const errorTests = [
315313
send: '1 }',
316314
expect: '{ a: 1 }'
317315
},
316+
// Multiline string-keyed object (e.g. JSON)
317+
{
318+
send: '{ "a": ',
319+
expect: '... '
320+
},
321+
{
322+
send: '1 }',
323+
expect: '{ a: 1 }'
324+
},
318325
// Multiline anonymous function with comment
319326
{
320327
send: '(function() {',

0 commit comments

Comments
 (0)