Bug 1594009 - Add optional chaining support for console autocomplete. r=Honza.

The JsPropertyProvider parser is modified to accept optional chaining
syntax.

Differential Revision: https://phabricator.services.mozilla.com/D60654

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Nicolas Chevobbe 2020-02-11 14:29:09 +00:00
Родитель b84237cbb9
Коммит 54a45e09dd
2 изменённых файлов: 204 добавлений и 49 удалений

Просмотреть файл

@ -101,58 +101,30 @@ function JSPropertyProvider({
// Analyse the inputValue and find the beginning of the last part that
// should be completed.
const { err, state, lastStatement, isElementAccess } = analyzeInputString(
inputValue
);
const inputAnalysis = analyzeInputString(inputValue);
// There was an error analysing the string.
if (err) {
console.error("Failed to analyze input string", err);
if (!shouldInputBeAutocompleted(inputAnalysis)) {
return null;
}
// If the current state is not STATE_NORMAL, then we are inside of an string
// which means that no completion is possible.
if (state != STATE_NORMAL) {
return null;
}
// Don't complete on just an empty string.
if (lastStatement.trim() == "") {
return null;
}
if (
NO_AUTOCOMPLETE_PREFIXES.some(prefix =>
lastStatement.startsWith(prefix + " ")
)
) {
return null;
}
const env = environment || dbgObject.asEnvironment();
const completionPart = lastStatement;
const lastDotIndex = completionPart.lastIndexOf(".");
const lastOpeningBracketIndex = isElementAccess
? completionPart.lastIndexOf("[")
: -1;
const lastCompletionCharIndex = Math.max(
lastDotIndex,
lastOpeningBracketIndex
);
const startQuoteRegex = /^('|"|`)/;
let {
mainExpression,
matchProp,
isPropertyAccess,
} = splitInputAtLastPropertyAccess(inputAnalysis);
const { lastStatement, isElementAccess } = inputAnalysis;
// AST representation of the expression before the last access char (`.` or `[`).
let astExpression;
let matchProp = completionPart.slice(lastCompletionCharIndex + 1).trimLeft();
const startQuoteRegex = /^('|"|`)/;
const env = environment || dbgObject.asEnvironment();
// Catch literals like [1,2,3] or "foo" and return the matches from
// their prototypes.
// Don't run this is a worker, migrating to acorn should allow this
// to run in a worker - Bug 1217198.
if (!isWorker && lastCompletionCharIndex > 0) {
const parsedExpression = completionPart.slice(0, lastCompletionCharIndex);
const syntaxTrees = getSyntaxTrees(parsedExpression);
if (!isWorker && isPropertyAccess) {
const syntaxTrees = getSyntaxTrees(mainExpression);
const lastTree = syntaxTrees[syntaxTrees.length - 1];
const lastBody = lastTree && lastTree.body[lastTree.body.length - 1];
@ -180,7 +152,7 @@ function JSPropertyProvider({
// (but the following are fine: `1..`, `(1.).`).
if (
!Number.isInteger(astExpression.value) ||
/\d[^\.]{0}\.$/.test(completionPart) === false
/\d[^\.]{0}\.$/.test(lastStatement) === false
) {
matchingObject = getContentPrototypeObject(env, "Number");
} else {
@ -215,7 +187,7 @@ function JSPropertyProvider({
let properties = [];
if (astExpression) {
if (lastCompletionCharIndex > -1) {
if (isPropertyAccess) {
properties = getPropertiesFromAstExpression(astExpression);
if (properties === null) {
@ -223,7 +195,7 @@ function JSPropertyProvider({
}
}
} else {
properties = completionPart.split(".");
properties = lastStatement.split(".");
if (isElementAccess) {
const lastPart = properties[properties.length - 1];
const openBracketIndex = lastPart.lastIndexOf("[");
@ -362,6 +334,93 @@ function JSPropertyProvider({
return prepareReturnedObject(getMatchedPropsInDbgObject(obj, search));
}
function shouldInputBeAutocompleted(inputAnalysisState) {
const { err, state, lastStatement } = inputAnalysisState;
// There was an error analysing the string.
if (err) {
console.error("Failed to analyze input string", err);
return false;
}
// If the current state is not STATE_NORMAL, then we are inside of an string
// which means that no completion is possible.
if (state != STATE_NORMAL) {
return false;
}
// Don't complete on just an empty string.
if (lastStatement.trim() == "") {
return false;
}
if (
NO_AUTOCOMPLETE_PREFIXES.some(prefix =>
lastStatement.startsWith(prefix + " ")
)
) {
return false;
}
return true;
}
/**
* Function that will process the result of analyzeInputString and return useful information
* about the expression.
*
* @param {Object} inputAnalysisState: Result of analyzeInputString.
* @returns {Object} An object of the following shape:
* - mainExpression: The part of the expression before any property access,
* (e.g. `a.b` if expression is `a.b.`)
* - matchProp: The part of the expression that should match the properties
* on the mainExpression (e.g. `que` when expression is `document.body.que`)
* - isPropertyAccess: true of the expression indicate that the
* expression has a property access (e.g. `a.x` or `a["x"`).
*/
function splitInputAtLastPropertyAccess(inputAnalysisState) {
const { lastStatement, isElementAccess } = inputAnalysisState;
const lastDotIndex = lastStatement.lastIndexOf(".");
const lastOpeningBracketIndex = isElementAccess
? lastStatement.lastIndexOf("[")
: -1;
const lastCompletionCharIndex = Math.max(
lastDotIndex,
lastOpeningBracketIndex
);
const matchProp = lastStatement.slice(lastCompletionCharIndex + 1).trimLeft();
const matchPropPrefix = lastStatement.slice(0, lastCompletionCharIndex + 1);
const optionalChainingElementAccessRegex = /\s*\?\.\s*\[\s*$/;
const isPropertyAccess = lastCompletionCharIndex > 0;
let mainExpression;
if (!isPropertyAccess) {
mainExpression = matchProp;
} else if (
isElementAccess &&
optionalChainingElementAccessRegex.test(matchPropPrefix)
) {
// Strip the optional chaining operator at the end;
mainExpression = matchPropPrefix.replace(
optionalChainingElementAccessRegex,
""
);
} else if (!isElementAccess && matchPropPrefix.endsWith("?.")) {
mainExpression = matchPropPrefix.slice(0, matchPropPrefix.length - 2);
} else {
mainExpression = matchPropPrefix.slice(0, matchPropPrefix.length - 1);
}
mainExpression = mainExpression.trim();
return {
mainExpression,
matchProp,
isPropertyAccess,
};
}
function hasArrayIndex(str) {
return /\[\d+\]$/.test(str);
}
@ -375,6 +434,7 @@ const STATE_SLASH = Symbol("STATE_SLASH");
const STATE_INLINE_COMMENT = Symbol("STATE_INLINE_COMMENT");
const STATE_MULTILINE_COMMENT = Symbol("STATE_MULTILINE_COMMENT");
const STATE_MULTILINE_COMMENT_CLOSE = Symbol("STATE_MULTILINE_COMMENT_CLOSE");
const STATE_QUESTION_MARK = Symbol("STATE_QUESTION_MARK");
const OPEN_BODY = "{[(".split("");
const CLOSE_BODY = "}])".split("");
@ -385,7 +445,7 @@ const OPEN_CLOSE_BODY = {
};
const NO_AUTOCOMPLETE_PREFIXES = ["var", "const", "let", "function", "class"];
const OPERATOR_CHARS_SET = new Set(";,:=<>+-*%|&^~?!".split(""));
const OPERATOR_CHARS_SET = new Set(";,:=<>+-*%|&^~!".split(""));
/**
* Analyses a given string to find the last statement that is interesting for
@ -438,10 +498,17 @@ function analyzeInputString(str) {
switch (state) {
// Normal JS state.
case STATE_NORMAL:
if (lastStatement.endsWith("?.") && /\d/.test(c)) {
// If the current char is a number, the engine will consider we're not
// performing an optional chaining, but a ternary (e.g. x ?.4 : 2).
lastStatement = "";
}
// If the last characters were spaces, and the current one is not.
if (pendingWhitespaceChars && !isWhitespaceChar) {
// If we have a legitimate property/element access, we append the spaces.
if (c === "[" || c === ".") {
// If we have a legitimate property/element access, or potential optional
// chaining call, we append the spaces.
if (c === "[" || c === "." || c === "?") {
lastStatement = lastStatement + pendingWhitespaceChars;
} else {
// if not, we can be sure the statement was over, and we can start a new one.
@ -458,6 +525,8 @@ function analyzeInputString(str) {
state = STATE_TEMPLATE_LITERAL;
} else if (c == "/") {
state = STATE_SLASH;
} else if (c == "?") {
state = STATE_QUESTION_MARK;
} else if (OPERATOR_CHARS_SET.has(c)) {
// If the character is an operator, we can update the current statement.
resetLastStatement = true;
@ -568,6 +637,18 @@ function analyzeInputString(str) {
state = STATE_MULTILINE_COMMENT;
}
break;
case STATE_QUESTION_MARK:
state = STATE_NORMAL;
if (c === "?") {
// If we have a nullish coalescing operator, we start a new statement
resetLastStatement = true;
} else if (c !== ".") {
// If we're not dealing with optional chaining (?.), it means we have a ternary,
// so we are starting a new statement that includes the current character.
lastStatement = "";
}
break;
}
if (!isWhitespaceChar) {
@ -644,12 +725,17 @@ function getPropertiesFromAstExpression(ast) {
if (!ast) {
return result;
}
const { type, property, object, name } = ast;
const { type, property, object, name, expression } = ast;
if (type === "ThisExpression") {
result.unshift("this");
} else if (type === "Identifier" && name) {
result.unshift(name);
} else if (type === "MemberExpression") {
} else if (type === "OptionalExpression" && expression) {
result = (getPropertiesFromAstExpression(expression) || []).concat(result);
} else if (
type === "MemberExpression" ||
type === "OptionalMemberExpression"
) {
if (property) {
if (property.type === "Identifier" && property.name) {
result.unshift(property.name);

Просмотреть файл

@ -594,6 +594,70 @@ function runChecks(dbgObject, environment, sandbox) {
});
test_has_result(results, "'propA'");
Assert.equal(results.matches.has("propLocal"), false);
info("Test that expression with optional chaining operator are completed");
results = propertyProvider("testObject?.prop");
test_has_result(results, "propA");
results = propertyProvider("testObject?.propA[0]?.propB?.to");
test_has_result(results, "toString");
results = propertyProvider("testObject?.propA?.[0]?.propB?.to");
test_has_result(results, "toString");
results = propertyProvider(
"testObject ?. propA[0] ?. propB ?. to"
);
test_has_result(results, "toString");
results = propertyProvider("testObject?.[prop");
test_has_result(results, '"propA"');
results = propertyProvider(`testObject?.["prop`);
test_has_result(results, '"propA"');
results = propertyProvider(`testObject?.['prop`);
test_has_result(results, `'propA'`);
results = propertyProvider(`testObject?.["propA"]?.[0]?.["propB"]?.["to`);
test_has_result(results, `"toString"`);
results = propertyProvider(
`testObject ?. ["propA"] ?. [0] ?. ["propB"] ?. ['to`
);
test_has_result(results, "'toString'");
results = propertyProvider("[1,2,3]?.");
test_has_result(results, "indexOf");
results = propertyProvider("'foo'?.");
test_has_result(results, "charAt");
results = propertyProvider("1?.");
test_has_result(results, "toFixed");
// check this doesn't throw since `propC` is not defined.
results = propertyProvider("testObject?.propC?.this?.does?.not?.exist?.d");
// check that ternary operator isn't mistaken for optional chaining
results = propertyProvider(`true?.3.to`);
test_has_result(results, `toExponential`);
results = propertyProvider(`true?.3?.to`);
test_has_result(results, `toExponential`);
// Test more ternary
results = propertyProvider(`true?t`);
test_has_result(results, `testObject`);
results = propertyProvider(`true??t`);
test_has_result(results, `testObject`);
results = propertyProvider(`true?/* comment */t`);
test_has_result(results, `testObject`);
results = propertyProvider(`true?<t`);
test_has_no_results(results);
}
/**
@ -615,7 +679,12 @@ function test_has_no_results(results) {
function test_has_result(results, requiredSuggestion) {
Assert.notEqual(results, null);
Assert.ok(results.matches.size > 0);
Assert.ok(results.matches.has(requiredSuggestion));
Assert.ok(
results.matches.has(requiredSuggestion),
`<${requiredSuggestion}> found in ${[...results.matches.values()].join(
" - "
)}`
);
}
/**