diff options
Diffstat (limited to 'devtools/shared/webconsole/analyze-input-string.js')
-rw-r--r-- | devtools/shared/webconsole/analyze-input-string.js | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/devtools/shared/webconsole/analyze-input-string.js b/devtools/shared/webconsole/analyze-input-string.js new file mode 100644 index 0000000000..a0e36247e3 --- /dev/null +++ b/devtools/shared/webconsole/analyze-input-string.js @@ -0,0 +1,406 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const STATE_NORMAL = Symbol("STATE_NORMAL"); +const STATE_QUOTE = Symbol("STATE_QUOTE"); +const STATE_DQUOTE = Symbol("STATE_DQUOTE"); +const STATE_TEMPLATE_LITERAL = Symbol("STATE_TEMPLATE_LITERAL"); +const STATE_ESCAPE_QUOTE = Symbol("STATE_ESCAPE_QUOTE"); +const STATE_ESCAPE_DQUOTE = Symbol("STATE_ESCAPE_DQUOTE"); +const STATE_ESCAPE_TEMPLATE_LITERAL = Symbol("STATE_ESCAPE_TEMPLATE_LITERAL"); +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(""); +const OPEN_CLOSE_BODY = { + "{": "}", + "[": "]", + "(": ")", +}; + +const NO_AUTOCOMPLETE_PREFIXES = ["var", "const", "let", "function", "class"]; +const OPERATOR_CHARS_SET = new Set(";,:=<>+-*%|&^~!".split("")); + +/** + * Analyses a given string to find the last statement that is interesting for + * later completion. + * + * @param string str + * A string to analyse. + * + * @returns object + * If there was an error in the string detected, then a object like + * + * { err: "ErrorMesssage" } + * + * is returned, otherwise a object like + * + * { + * state: STATE_NORMAL|STATE_QUOTE|STATE_DQUOTE, + * lastStatement: the last statement in the string, + * isElementAccess: boolean that indicates if the lastStatement has an open + * element access (e.g. `x["match`). + * isPropertyAccess: boolean indicating if we are accessing property + * (e.g `true` in `var a = {b: 1};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`) + * mainExpression: The part of the expression before any property access, + * (e.g. `a.b` if expression is `a.b.`) + * expressionBeforePropertyAccess: The part of the expression before property access + * (e.g `var a = {b: 1};a` if expression is `var a = {b: 1};a.b`) + * } + */ +// eslint-disable-next-line complexity +exports.analyzeInputString = function (str, timeout = 2500) { + // work variables. + const bodyStack = []; + let state = STATE_NORMAL; + let previousNonWhitespaceChar; + let lastStatement = ""; + let currentIndex = -1; + let dotIndex; + let pendingWhitespaceChars = ""; + const startingTime = Date.now(); + + // Use a string iterator in order to handle character with a length >= 2 (e.g. 😎). + for (const c of str) { + // We are possibly dealing with a very large string that would take a long time to + // analyze (and freeze the process). If the function has been running for more than + // a given time, we stop the analysis (this isn't too bad because the only + // consequence is that we won't provide autocompletion items). + if (Date.now() - startingTime > timeout) { + return { + err: "timeout", + }; + } + + currentIndex += 1; + let resetLastStatement = false; + const isWhitespaceChar = c.trim() === ""; + 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 = ""; + } + + // Storing the index of dot of the input string + if (c === ".") { + dotIndex = currentIndex; + } + + // If the last characters were spaces, and the current one is not. + if (pendingWhitespaceChars && !isWhitespaceChar) { + // 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. + lastStatement = ""; + } + pendingWhitespaceChars = ""; + } + + if (c == '"') { + state = STATE_DQUOTE; + } else if (c == "'") { + state = STATE_QUOTE; + } else if (c == "`") { + 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; + } else if (isWhitespaceChar) { + // If the previous char isn't a dot or opening bracket, and the current computed + // statement is not a variable/function/class declaration, we track the number + // of consecutive spaces, so we can re-use them at some point (or drop them). + if ( + previousNonWhitespaceChar !== "." && + previousNonWhitespaceChar !== "[" && + !NO_AUTOCOMPLETE_PREFIXES.includes(lastStatement) + ) { + pendingWhitespaceChars += c; + continue; + } + } else if (OPEN_BODY.includes(c)) { + // When opening a bracket or a parens, we store the current statement, in order + // to be able to retrieve it later. + bodyStack.push({ + token: c, + lastStatement, + index: currentIndex, + }); + // And we compute a new statement. + resetLastStatement = true; + } else if (CLOSE_BODY.includes(c)) { + const last = bodyStack.pop(); + if (!last || OPEN_CLOSE_BODY[last.token] != c) { + return { + err: "syntax error", + }; + } + if (c == "}") { + resetLastStatement = true; + } else { + lastStatement = last.lastStatement; + } + } + break; + + // Escaped quote + case STATE_ESCAPE_QUOTE: + state = STATE_QUOTE; + break; + case STATE_ESCAPE_DQUOTE: + state = STATE_DQUOTE; + break; + case STATE_ESCAPE_TEMPLATE_LITERAL: + state = STATE_TEMPLATE_LITERAL; + break; + + // Double quote state > " < + case STATE_DQUOTE: + if (c == "\\") { + state = STATE_ESCAPE_DQUOTE; + } else if (c == "\n") { + return { + err: "unterminated string literal", + }; + } else if (c == '"') { + state = STATE_NORMAL; + } + break; + + // Template literal state > ` < + case STATE_TEMPLATE_LITERAL: + if (c == "\\") { + state = STATE_ESCAPE_TEMPLATE_LITERAL; + } else if (c == "`") { + state = STATE_NORMAL; + } + break; + + // Single quote state > ' < + case STATE_QUOTE: + if (c == "\\") { + state = STATE_ESCAPE_QUOTE; + } else if (c == "\n") { + return { + err: "unterminated string literal", + }; + } else if (c == "'") { + state = STATE_NORMAL; + } + break; + case STATE_SLASH: + if (c == "/") { + state = STATE_INLINE_COMMENT; + } else if (c == "*") { + state = STATE_MULTILINE_COMMENT; + } else { + lastStatement = ""; + state = STATE_NORMAL; + } + break; + + case STATE_INLINE_COMMENT: + if (c === "\n") { + state = STATE_NORMAL; + resetLastStatement = true; + } + break; + + case STATE_MULTILINE_COMMENT: + if (c === "*") { + state = STATE_MULTILINE_COMMENT_CLOSE; + } + break; + + case STATE_MULTILINE_COMMENT_CLOSE: + if (c === "/") { + state = STATE_NORMAL; + resetLastStatement = true; + } else { + 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 = ""; + } else { + dotIndex = currentIndex; + } + break; + } + + if (!isWhitespaceChar) { + previousNonWhitespaceChar = c; + } + if (resetLastStatement) { + lastStatement = ""; + } else { + lastStatement = lastStatement + c; + } + + // We update all the open stacks lastStatement so they are up-to-date. + bodyStack.forEach(stack => { + if (stack.token !== "}") { + stack.lastStatement = stack.lastStatement + c; + } + }); + } + + let isElementAccess = false; + let lastOpeningBracketIndex = -1; + if (bodyStack.length === 1 && bodyStack[0].token === "[") { + lastStatement = bodyStack[0].lastStatement; + lastOpeningBracketIndex = bodyStack[0].index; + isElementAccess = true; + + if ( + state === STATE_DQUOTE || + state === STATE_QUOTE || + state === STATE_TEMPLATE_LITERAL || + state === STATE_ESCAPE_QUOTE || + state === STATE_ESCAPE_DQUOTE || + state === STATE_ESCAPE_TEMPLATE_LITERAL + ) { + state = STATE_NORMAL; + } + } else if (pendingWhitespaceChars) { + lastStatement = ""; + } + + const lastCompletionCharIndex = isElementAccess + ? lastOpeningBracketIndex + : dotIndex; + + const stringBeforeLastCompletionChar = str.slice(0, lastCompletionCharIndex); + + const isPropertyAccess = + lastCompletionCharIndex && lastCompletionCharIndex > 0; + + // Compute `isOptionalAccess`, so that we can use it + // later for computing `expressionBeforePropertyAccess`. + //Check `?.` before `[` for element access ( e.g `a?.["b` or `a ?. ["b` ) + // and `?` before `.` for regular property access ( e.g `a?.b` or `a ?. b` ) + const optionalElementAccessRegex = /\?\.\s*$/; + const isOptionalAccess = isElementAccess + ? optionalElementAccessRegex.test(stringBeforeLastCompletionChar) + : isPropertyAccess && + str.slice(lastCompletionCharIndex - 1, lastCompletionCharIndex + 1) === + "?."; + + // Get the filtered string for the properties (e.g if `document.qu` then `qu`) + const matchProp = isPropertyAccess + ? str.slice(lastCompletionCharIndex + 1).trimLeft() + : null; + + const expressionBeforePropertyAccess = isPropertyAccess + ? str.slice( + 0, + // For optional access, we can take all the chars before the last "?" char. + isOptionalAccess + ? stringBeforeLastCompletionChar.lastIndexOf("?") + : lastCompletionCharIndex + ) + : str; + + let mainExpression = lastStatement; + if (isPropertyAccess) { + if (isOptionalAccess) { + // Strip anything before the last `?`. + mainExpression = mainExpression.slice(0, mainExpression.lastIndexOf("?")); + } else { + mainExpression = mainExpression.slice( + 0, + -1 * (str.length - lastCompletionCharIndex) + ); + } + } + + mainExpression = mainExpression.trim(); + + return { + state, + isElementAccess, + isPropertyAccess, + expressionBeforePropertyAccess, + lastStatement, + mainExpression, + matchProp, + }; +}; + +/** + * Checks whether the analyzed input string is in an appropriate state to autocomplete, e.g. not + * inside a string, or declaring a variable. + * @param {object} inputAnalysisState The analyzed string to check + * @returns {boolean} Whether the input should be autocompleted + */ +exports.shouldInputBeAutocompleted = function (inputAnalysisState) { + const { err, state, lastStatement } = inputAnalysisState; + + // There was an error analysing the string. + if (err) { + return false; + } + + // If the current state is not STATE_NORMAL, then we are inside 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; +}; + +/** + * Checks whether the analyzed input string is in an appropriate state to be eagerly evaluated. + * @param {object} inputAnalysisState + * @returns {boolean} Whether the input should be eagerly evaluated + */ +exports.shouldInputBeEagerlyEvaluated = function ({ lastStatement }) { + const inComputedProperty = + lastStatement.lastIndexOf("[") !== -1 && + lastStatement.lastIndexOf("[") > lastStatement.lastIndexOf("]"); + + const hasPropertyAccess = + lastStatement.includes(".") || lastStatement.includes("["); + + return hasPropertyAccess && !inComputedProperty; +}; |