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
Next Next commit
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.
  • Loading branch information
rubys committed Aug 2, 2018
commit 9affc6f44839d49cf438cfbcf1279648df58f1ce
113 changes: 51 additions & 62 deletions lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ const {
makeRequireFunction,
addBuiltinLibsToObject
} = require('internal/modules/cjs/helpers');
const acorn = require('internal/deps/acorn/dist/acorn');
const {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these additions be placed near the other acorn work in lib/internal/repl/?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by these additions here. The require isn't new, just the capturing of the name as acorn is new. More generally, isRecoverableError isn't new, just completely rewritten.

Perhaps you are suggesting that isRecoverableError be split out into a separate file and placed into lib/internal/repl/? If so, I can do that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm suggesting keeping the acorn extensions and other acorn bits closer together.

isIdentifierStart,
isIdentifierChar
} = require('internal/deps/acorn/dist/acorn');
} = acorn;
const internalUtil = require('internal/util');
const { isTypedArray } = require('internal/util/types');
const util = require('util');
Expand Down Expand Up @@ -1507,72 +1508,60 @@ function regexpEscape(s) {

// 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) {
if (e && e.name === 'SyntaxError') {
var message = e.message;
if (message === 'Unterminated template literal' ||
message === 'Unexpected end of input') {
return true;
}
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.
acorn.plugins.repl = (parser) => {
parser.extend('nextToken', (nextToken) => {
return function(pos, message) {
nextToken.call(this, pos, message);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of nextToken.call and other .call's we should have const ReflectApply = Reflect.apply and then use it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my education, can I ask why? First, I don't see const ReflectApply = Reflect.apply as a common pattern inside of the node codebase (I only found two occurrences, and even those two differed). Second, I believe that .call is more concise and clearer (at a minimum, I wouldn't need to put [] around the arguments). What am I missing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since core is not run in its own isolated context but in the one shared with the user, its builtins are the same ones that user-land can touch and interact with. Grabbing references to builtin prototype methods and static builtin methods before user-land code executes is an attempt to strengthen the core against accidental user-land augmentation. While handled inconsistently in Node core, new code added seems to have these guards. It's reasonable, where possible, to keep that up.

The use of ReflectApply covers the Function#call case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdalton there was a long discussion around this but no conclusion. As long as this is not something everyone agrees to I think it is best to keep these things to the PR openers.

if (parser.type === acorn.tokTypes.eof) recoverable = true;
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acorn.tokTypes is commonly stored in a var called tt in acorn plugins.
So then it would be tt.eof.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe parse.type can be this.type

});

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;
}
parser.extend('raise', (raise) => {
return function(pos, message) {
switch (message) {
case 'Unterminated template':
case 'Unterminated comment':
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👆 Might put a note that these message strings are subject to change and to periodically check in on them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll ponder how to word that. Meanwhile, I'll note that should these message strings change, a test will fail.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to manually update acorn to run into this issue. Having a failed test in such a circumstance would be fine and we'd have to invest that one way or the other. So I guess it is fine to keep it is as.

recoverable = true;
break;

case 'Unterminated string constant':
const token = parser.input.slice(parser.lastTokStart, parser.pos);
recoverable = token.endsWith('\\\n');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit as above, inconsistent use of parser vs. this.

}

if (message === 'Invalid or unexpected token')
return isCodeRecoverable(code);
}
return false;
}
raise.call(this, pos, message);
};
});
};

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👆 Acorn has a thorough regexp for whitespace. Might use that for the leading optional whitespace check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I think it is very important that the regular expression exactly match the one in defaultEval as it would be problematic for this code to treat a given input as recoverable only to later find out that defaultEval would consider it to be a syntax error when done.

// 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: { repl: true } });
return true;
} catch (e) {
return recoverable;
}

return stringLiteral ? lastChar === '\\' : isBlockComment;
}

function Recoverable(err) {
Expand Down
21 changes: 14 additions & 7 deletions test/parallel/test-repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ${',
Expand Down Expand Up @@ -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() {',
Expand Down