summaryrefslogtreecommitdiffstats
path: root/devtools/shared/webconsole/analyze-input-string.js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/shared/webconsole/analyze-input-string.js
parentInitial commit. (diff)
downloadfirefox-upstream/124.0.1.tar.xz
firefox-upstream/124.0.1.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/webconsole/analyze-input-string.js')
-rw-r--r--devtools/shared/webconsole/analyze-input-string.js406
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;
+};