summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/sourceeditor/css-autocompleter.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/sourceeditor/css-autocompleter.js')
-rw-r--r--devtools/client/shared/sourceeditor/css-autocompleter.js1248
1 files changed, 1248 insertions, 0 deletions
diff --git a/devtools/client/shared/sourceeditor/css-autocompleter.js b/devtools/client/shared/sourceeditor/css-autocompleter.js
new file mode 100644
index 0000000000..7db3cbbc0f
--- /dev/null
+++ b/devtools/client/shared/sourceeditor/css-autocompleter.js
@@ -0,0 +1,1248 @@
+/* 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 {
+ cssTokenizer,
+ cssTokenizerWithLineColumn,
+} = require("resource://devtools/shared/css/parsing-utils.js");
+
+/**
+ * Here is what this file (+ css-parsing-utils.js) do.
+ *
+ * The main objective here is to provide as much suggestions to the user editing
+ * a stylesheet in Style Editor. The possible things that can be suggested are:
+ * - CSS property names
+ * - CSS property values
+ * - CSS Selectors
+ * - Some other known CSS keywords
+ *
+ * Gecko provides a list of both property names and their corresponding values.
+ * We take out a list of matching selectors using the Inspector actor's
+ * `getSuggestionsForQuery` method. Now the only thing is to parse the CSS being
+ * edited by the user, figure out what token or word is being written and last
+ * but the most difficult, what is being edited.
+ *
+ * The file 'css-parsing-utils' helps to convert the CSS into meaningful tokens,
+ * each having a certain type associated with it. These tokens help us to figure
+ * out the currently edited word and to write a CSS state machine to figure out
+ * what the user is currently editing. By that, I mean, whether he is editing a
+ * selector or a property or a value, or even fine grained information like an
+ * id in the selector.
+ *
+ * The `resolveState` method iterated over the tokens spitted out by the
+ * tokenizer, using switch cases, follows a state machine logic and finally
+ * figures out these informations:
+ * - The state of the CSS at the cursor (one out of CSS_STATES)
+ * - The current token that is being edited `cmpleting`
+ * - If the state is "selector", the selector state (one of SELECTOR_STATES)
+ * - If the state is "selector", the current selector till the cursor
+ * - If the state is "value", the corresponding property name
+ *
+ * In case of "value" and "property" states, we simply use the information
+ * provided by Gecko to filter out the possible suggestions.
+ * For "selector" state, we request the Inspector actor to query the page DOM
+ * and filter out the possible suggestions.
+ * For "media" and "keyframes" state, the only possible suggestions for now are
+ * "media" and "keyframes" respectively, although "media" can have suggestions
+ * like "max-width", "orientation" etc. Similarly "value" state can also have
+ * much better logical suggestions if we fine grain identify a sub state just
+ * like we do for the "selector" state.
+ */
+
+// Autocompletion types.
+
+const CSS_STATES = {
+ null: "null",
+ property: "property", // foo { bar|: … }
+ value: "value", // foo {bar: baz|}
+ selector: "selector", // f| {bar: baz}
+ media: "media", // @med| , or , @media scr| { }
+ keyframes: "keyframes", // @keyf|
+ frame: "frame", // @keyframs foobar { t|
+};
+
+const SELECTOR_STATES = {
+ null: "null",
+ id: "id", // #f|
+ class: "class", // #foo.b|
+ tag: "tag", // fo|
+ pseudo: "pseudo", // foo:|
+ attribute: "attribute", // foo[b|
+ value: "value", // foo[bar=b|
+};
+
+/**
+ * Constructor for the autocompletion object.
+ *
+ * @param options {Object} An options object containing the following options:
+ * - walker {Object} The object used for query selecting from the current
+ * target's DOM.
+ * - maxEntries {Number} Maximum selectors suggestions to display.
+ * - cssProperties {Object} The database of CSS properties.
+ */
+function CSSCompleter(options = {}) {
+ this.walker = options.walker;
+ this.maxEntries = options.maxEntries || 15;
+ this.cssProperties = options.cssProperties;
+
+ this.propertyNames = this.cssProperties.getNames().sort();
+
+ // Array containing the [line, ch, scopeStack] for the locations where the
+ // CSS state is "null"
+ this.nullStates = [];
+}
+
+CSSCompleter.prototype = {
+ /**
+ * Returns a list of suggestions based on the caret position.
+ *
+ * @param source {String} String of the source code.
+ * @param caret {Object} Cursor location with line and ch properties.
+ *
+ * @returns [{object}] A sorted list of objects containing the following
+ * peroperties:
+ * - label {String} Full keyword for the suggestion
+ * - preLabel {String} Already entered part of the label
+ */
+ complete(source, caret) {
+ // Getting the context from the caret position.
+ if (!this.resolveState(source, caret)) {
+ // We couldn't resolve the context, we won't be able to complete.
+ return Promise.resolve([]);
+ }
+
+ // Properly suggest based on the state.
+ switch (this.state) {
+ case CSS_STATES.property:
+ return this.completeProperties(this.completing);
+
+ case CSS_STATES.value:
+ return this.completeValues(this.propertyName, this.completing);
+
+ case CSS_STATES.selector:
+ return this.suggestSelectors();
+
+ case CSS_STATES.media:
+ case CSS_STATES.keyframes:
+ if ("media".startsWith(this.completing)) {
+ return Promise.resolve([
+ {
+ label: "media",
+ preLabel: this.completing,
+ text: "media",
+ },
+ ]);
+ } else if ("keyframes".startsWith(this.completing)) {
+ return Promise.resolve([
+ {
+ label: "keyframes",
+ preLabel: this.completing,
+ text: "keyframes",
+ },
+ ]);
+ }
+ }
+ return Promise.resolve([]);
+ },
+
+ /**
+ * Resolves the state of CSS at the cursor location. This method implements a
+ * custom written CSS state machine. The various switch statements provide the
+ * transition rules for the state. It also finds out various informatino about
+ * the nearby CSS like the property name being completed, the complete
+ * selector, etc.
+ *
+ * @param source {String} String of the source code.
+ * @param caret {Object} Cursor location with line and ch properties.
+ *
+ * @returns CSS_STATE
+ * One of CSS_STATE enum or null if the state cannot be resolved.
+ */
+ // eslint-disable-next-line complexity
+ resolveState(source, { line, ch }) {
+ // Function to return the last element of an array
+ const peek = arr => arr[arr.length - 1];
+ // _state can be one of CSS_STATES;
+ let _state = CSS_STATES.null;
+ let selector = "";
+ let selectorState = SELECTOR_STATES.null;
+ let propertyName = null;
+ let scopeStack = [];
+ let selectors = [];
+
+ // Fetch the closest null state line, ch from cached null state locations
+ const matchedStateIndex = this.findNearestNullState(line);
+ if (matchedStateIndex > -1) {
+ const state = this.nullStates[matchedStateIndex];
+ line -= state[0];
+ if (line == 0) {
+ ch -= state[1];
+ }
+ source = source.split("\n").slice(state[0]);
+ source[0] = source[0].slice(state[1]);
+ source = source.join("\n");
+ scopeStack = [...state[2]];
+ this.nullStates.length = matchedStateIndex + 1;
+ } else {
+ this.nullStates = [];
+ }
+ const tokens = cssTokenizerWithLineColumn(source);
+ const tokIndex = tokens.length - 1;
+ if (
+ tokIndex >= 0 &&
+ (tokens[tokIndex].loc.end.line < line ||
+ (tokens[tokIndex].loc.end.line === line &&
+ tokens[tokIndex].loc.end.column < ch))
+ ) {
+ // If the last token ends before the cursor location, we didn't
+ // tokenize it correctly. This special case can happen if the
+ // final token is a comment.
+ return null;
+ }
+
+ let cursor = 0;
+ // This will maintain a stack of paired elements like { & }, @m & }, : & ;
+ // etc
+ let token = null;
+ let selectorBeforeNot = null;
+ while (cursor <= tokIndex && (token = tokens[cursor++])) {
+ switch (_state) {
+ case CSS_STATES.property:
+ // From CSS_STATES.property, we can either go to CSS_STATES.value
+ // state when we hit the first ':' or CSS_STATES.selector if "}" is
+ // reached.
+ if (token.tokenType === "symbol") {
+ switch (token.text) {
+ case ":":
+ scopeStack.push(":");
+ if (tokens[cursor - 2].tokenType != "whitespace") {
+ propertyName = tokens[cursor - 2].text;
+ } else {
+ propertyName = tokens[cursor - 3].text;
+ }
+ _state = CSS_STATES.value;
+ break;
+
+ case "}":
+ if (/[{f]/.test(peek(scopeStack))) {
+ const popped = scopeStack.pop();
+ if (popped == "f") {
+ _state = CSS_STATES.frame;
+ } else {
+ selector = "";
+ selectors = [];
+ _state = CSS_STATES.null;
+ }
+ }
+ break;
+ }
+ }
+ break;
+
+ case CSS_STATES.value:
+ // From CSS_STATES.value, we can go to one of CSS_STATES.property,
+ // CSS_STATES.frame, CSS_STATES.selector and CSS_STATES.null
+ if (token.tokenType === "symbol") {
+ switch (token.text) {
+ case ";":
+ if (/[:]/.test(peek(scopeStack))) {
+ scopeStack.pop();
+ _state = CSS_STATES.property;
+ }
+ break;
+
+ case "}":
+ if (peek(scopeStack) == ":") {
+ scopeStack.pop();
+ }
+
+ if (/[{f]/.test(peek(scopeStack))) {
+ const popped = scopeStack.pop();
+ if (popped == "f") {
+ _state = CSS_STATES.frame;
+ } else {
+ selector = "";
+ selectors = [];
+ _state = CSS_STATES.null;
+ }
+ }
+ break;
+ }
+ }
+ break;
+
+ case CSS_STATES.selector:
+ // From CSS_STATES.selector, we can only go to CSS_STATES.property
+ // when we hit "{"
+ if (token.tokenType === "symbol" && token.text == "{") {
+ scopeStack.push("{");
+ _state = CSS_STATES.property;
+ selectors.push(selector);
+ selector = "";
+ break;
+ }
+
+ switch (selectorState) {
+ case SELECTOR_STATES.id:
+ case SELECTOR_STATES.class:
+ case SELECTOR_STATES.tag:
+ switch (token.tokenType) {
+ case "hash":
+ case "id":
+ selectorState = SELECTOR_STATES.id;
+ selector += "#" + token.text;
+ break;
+
+ case "symbol":
+ if (token.text == ".") {
+ selectorState = SELECTOR_STATES.class;
+ selector += ".";
+ if (
+ cursor <= tokIndex &&
+ tokens[cursor].tokenType == "ident"
+ ) {
+ token = tokens[cursor++];
+ selector += token.text;
+ }
+ } else if (token.text == "#") {
+ selectorState = SELECTOR_STATES.id;
+ selector += "#";
+ } else if (/[>~+]/.test(token.text)) {
+ selectorState = SELECTOR_STATES.null;
+ selector += token.text;
+ } else if (token.text == ",") {
+ selectorState = SELECTOR_STATES.null;
+ selectors.push(selector);
+ selector = "";
+ } else if (token.text == ":") {
+ selectorState = SELECTOR_STATES.pseudo;
+ selector += ":";
+ if (cursor > tokIndex) {
+ break;
+ }
+
+ token = tokens[cursor++];
+ switch (token.tokenType) {
+ case "function":
+ if (token.text == "not") {
+ selectorBeforeNot = selector;
+ selector = "";
+ scopeStack.push("(");
+ } else {
+ selector += token.text + "(";
+ }
+ selectorState = SELECTOR_STATES.null;
+ break;
+
+ case "ident":
+ selector += token.text;
+ break;
+ }
+ } else if (token.text == "[") {
+ selectorState = SELECTOR_STATES.attribute;
+ scopeStack.push("[");
+ selector += "[";
+ } else if (token.text == ")") {
+ if (peek(scopeStack) == "(") {
+ scopeStack.pop();
+ selector = selectorBeforeNot + "not(" + selector + ")";
+ selectorBeforeNot = null;
+ } else {
+ selector += ")";
+ }
+ selectorState = SELECTOR_STATES.null;
+ }
+ break;
+
+ case "whitespace":
+ selectorState = SELECTOR_STATES.null;
+ selector && (selector += " ");
+ break;
+ }
+ break;
+
+ case SELECTOR_STATES.null:
+ // From SELECTOR_STATES.null state, we can go to one of
+ // SELECTOR_STATES.id, SELECTOR_STATES.class or
+ // SELECTOR_STATES.tag
+ switch (token.tokenType) {
+ case "hash":
+ case "id":
+ selectorState = SELECTOR_STATES.id;
+ selector += "#" + token.text;
+ break;
+
+ case "ident":
+ selectorState = SELECTOR_STATES.tag;
+ selector += token.text;
+ break;
+
+ case "symbol":
+ if (token.text == ".") {
+ selectorState = SELECTOR_STATES.class;
+ selector += ".";
+ if (
+ cursor <= tokIndex &&
+ tokens[cursor].tokenType == "ident"
+ ) {
+ token = tokens[cursor++];
+ selector += token.text;
+ }
+ } else if (token.text == "#") {
+ selectorState = SELECTOR_STATES.id;
+ selector += "#";
+ } else if (token.text == "*") {
+ selectorState = SELECTOR_STATES.tag;
+ selector += "*";
+ } else if (/[>~+]/.test(token.text)) {
+ selector += token.text;
+ } else if (token.text == ",") {
+ selectorState = SELECTOR_STATES.null;
+ selectors.push(selector);
+ selector = "";
+ } else if (token.text == ":") {
+ selectorState = SELECTOR_STATES.pseudo;
+ selector += ":";
+ if (cursor > tokIndex) {
+ break;
+ }
+
+ token = tokens[cursor++];
+ switch (token.tokenType) {
+ case "function":
+ if (token.text == "not") {
+ selectorBeforeNot = selector;
+ selector = "";
+ scopeStack.push("(");
+ } else {
+ selector += token.text + "(";
+ }
+ selectorState = SELECTOR_STATES.null;
+ break;
+
+ case "ident":
+ selector += token.text;
+ break;
+ }
+ } else if (token.text == "[") {
+ selectorState = SELECTOR_STATES.attribute;
+ scopeStack.push("[");
+ selector += "[";
+ } else if (token.text == ")") {
+ if (peek(scopeStack) == "(") {
+ scopeStack.pop();
+ selector = selectorBeforeNot + "not(" + selector + ")";
+ selectorBeforeNot = null;
+ } else {
+ selector += ")";
+ }
+ selectorState = SELECTOR_STATES.null;
+ }
+ break;
+
+ case "whitespace":
+ selector && (selector += " ");
+ break;
+ }
+ break;
+
+ case SELECTOR_STATES.pseudo:
+ switch (token.tokenType) {
+ case "symbol":
+ if (/[>~+]/.test(token.text)) {
+ selectorState = SELECTOR_STATES.null;
+ selector += token.text;
+ } else if (token.text == ",") {
+ selectorState = SELECTOR_STATES.null;
+ selectors.push(selector);
+ selector = "";
+ } else if (token.text == ":") {
+ selectorState = SELECTOR_STATES.pseudo;
+ selector += ":";
+ if (cursor > tokIndex) {
+ break;
+ }
+
+ token = tokens[cursor++];
+ switch (token.tokenType) {
+ case "function":
+ if (token.text == "not") {
+ selectorBeforeNot = selector;
+ selector = "";
+ scopeStack.push("(");
+ } else {
+ selector += token.text + "(";
+ }
+ selectorState = SELECTOR_STATES.null;
+ break;
+
+ case "ident":
+ selector += token.text;
+ break;
+ }
+ } else if (token.text == "[") {
+ selectorState = SELECTOR_STATES.attribute;
+ scopeStack.push("[");
+ selector += "[";
+ }
+ break;
+
+ case "whitespace":
+ selectorState = SELECTOR_STATES.null;
+ selector && (selector += " ");
+ break;
+ }
+ break;
+
+ case SELECTOR_STATES.attribute:
+ switch (token.tokenType) {
+ case "symbol":
+ if (/[~|^$*]/.test(token.text)) {
+ selector += token.text;
+ token = tokens[cursor++];
+ } else if (token.text == "=") {
+ selectorState = SELECTOR_STATES.value;
+ selector += token.text;
+ } else if (token.text == "]") {
+ if (peek(scopeStack) == "[") {
+ scopeStack.pop();
+ }
+
+ selectorState = SELECTOR_STATES.null;
+ selector += "]";
+ }
+ break;
+
+ case "ident":
+ case "string":
+ selector += token.text;
+ break;
+
+ case "whitespace":
+ selector && (selector += " ");
+ break;
+ }
+ break;
+
+ case SELECTOR_STATES.value:
+ switch (token.tokenType) {
+ case "string":
+ case "ident":
+ selector += token.text;
+ break;
+
+ case "symbol":
+ if (token.text == "]") {
+ if (peek(scopeStack) == "[") {
+ scopeStack.pop();
+ }
+
+ selectorState = SELECTOR_STATES.null;
+ selector += "]";
+ }
+ break;
+
+ case "whitespace":
+ selector && (selector += " ");
+ break;
+ }
+ break;
+ }
+ break;
+
+ case CSS_STATES.null:
+ // From CSS_STATES.null state, we can go to either CSS_STATES.media or
+ // CSS_STATES.selector.
+ switch (token.tokenType) {
+ case "hash":
+ case "id":
+ selectorState = SELECTOR_STATES.id;
+ selector = "#" + token.text;
+ _state = CSS_STATES.selector;
+ break;
+
+ case "ident":
+ selectorState = SELECTOR_STATES.tag;
+ selector = token.text;
+ _state = CSS_STATES.selector;
+ break;
+
+ case "symbol":
+ if (token.text == ".") {
+ selectorState = SELECTOR_STATES.class;
+ selector = ".";
+ _state = CSS_STATES.selector;
+ if (cursor <= tokIndex && tokens[cursor].tokenType == "ident") {
+ token = tokens[cursor++];
+ selector += token.text;
+ }
+ } else if (token.text == "#") {
+ selectorState = SELECTOR_STATES.id;
+ selector = "#";
+ _state = CSS_STATES.selector;
+ } else if (token.text == "*") {
+ selectorState = SELECTOR_STATES.tag;
+ selector = "*";
+ _state = CSS_STATES.selector;
+ } else if (token.text == ":") {
+ _state = CSS_STATES.selector;
+ selectorState = SELECTOR_STATES.pseudo;
+ selector += ":";
+ if (cursor > tokIndex) {
+ break;
+ }
+
+ token = tokens[cursor++];
+ switch (token.tokenType) {
+ case "function":
+ if (token.text == "not") {
+ selectorBeforeNot = selector;
+ selector = "";
+ scopeStack.push("(");
+ } else {
+ selector += token.text + "(";
+ }
+ selectorState = SELECTOR_STATES.null;
+ break;
+
+ case "ident":
+ selector += token.text;
+ break;
+ }
+ } else if (token.text == "[") {
+ _state = CSS_STATES.selector;
+ selectorState = SELECTOR_STATES.attribute;
+ scopeStack.push("[");
+ selector += "[";
+ } else if (token.text == "}") {
+ if (peek(scopeStack) == "@m") {
+ scopeStack.pop();
+ }
+ }
+ break;
+
+ case "at":
+ _state = token.text.startsWith("m")
+ ? CSS_STATES.media
+ : CSS_STATES.keyframes;
+ break;
+ }
+ break;
+
+ case CSS_STATES.media:
+ // From CSS_STATES.media, we can only go to CSS_STATES.null state when
+ // we hit the first '{'
+ if (token.tokenType == "symbol" && token.text == "{") {
+ scopeStack.push("@m");
+ _state = CSS_STATES.null;
+ }
+ break;
+
+ case CSS_STATES.keyframes:
+ // From CSS_STATES.keyframes, we can only go to CSS_STATES.frame state
+ // when we hit the first '{'
+ if (token.tokenType == "symbol" && token.text == "{") {
+ scopeStack.push("@k");
+ _state = CSS_STATES.frame;
+ }
+ break;
+
+ case CSS_STATES.frame:
+ // From CSS_STATES.frame, we can either go to CSS_STATES.property
+ // state when we hit the first '{' or to CSS_STATES.selector when we
+ // hit '}'
+ if (token.tokenType == "symbol") {
+ if (token.text == "{") {
+ scopeStack.push("f");
+ _state = CSS_STATES.property;
+ } else if (token.text == "}") {
+ if (peek(scopeStack) == "@k") {
+ scopeStack.pop();
+ }
+
+ _state = CSS_STATES.null;
+ }
+ }
+ break;
+ }
+ if (_state == CSS_STATES.null) {
+ if (!this.nullStates.length) {
+ this.nullStates.push([
+ token.loc.end.line,
+ token.loc.end.column,
+ [...scopeStack],
+ ]);
+ continue;
+ }
+ let tokenLine = token.loc.end.line;
+ const tokenCh = token.loc.end.column;
+ if (tokenLine == 0) {
+ continue;
+ }
+ if (matchedStateIndex > -1) {
+ tokenLine += this.nullStates[matchedStateIndex][0];
+ }
+ this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]);
+ }
+ }
+ this.state = _state;
+ this.propertyName = _state == CSS_STATES.value ? propertyName : null;
+ this.selectorState = _state == CSS_STATES.selector ? selectorState : null;
+ this.selectorBeforeNot =
+ selectorBeforeNot == null ? null : selectorBeforeNot;
+ if (token) {
+ selector = selector.slice(0, selector.length + token.loc.end.column - ch);
+ this.selector = selector;
+ } else {
+ this.selector = "";
+ }
+ this.selectors = selectors;
+
+ if (token && token.tokenType != "whitespace") {
+ let text;
+ if (token.tokenType == "dimension" || !token.text) {
+ text = source.substring(token.startOffset, token.endOffset);
+ } else {
+ text = token.text;
+ }
+ this.completing = text
+ .slice(0, ch - token.loc.start.column)
+ .replace(/^[.#]$/, "");
+ } else {
+ this.completing = "";
+ }
+ // Special case the situation when the user just entered ":" after typing a
+ // property name.
+ if (this.completing == ":" && _state == CSS_STATES.value) {
+ this.completing = "";
+ }
+
+ // Special check for !important; case.
+ if (
+ token &&
+ tokens[cursor - 2] &&
+ tokens[cursor - 2].text == "!" &&
+ this.completing == "important".slice(0, this.completing.length)
+ ) {
+ this.completing = "!" + this.completing;
+ }
+ return _state;
+ },
+
+ /**
+ * Queries the DOM Walker actor for suggestions regarding the selector being
+ * completed
+ */
+ suggestSelectors() {
+ const walker = this.walker;
+ if (!walker) {
+ return Promise.resolve([]);
+ }
+
+ let query = this.selector;
+ // Even though the selector matched atleast one node, there is still
+ // possibility of suggestions.
+ switch (this.selectorState) {
+ case SELECTOR_STATES.null:
+ if (this.completing === ",") {
+ return Promise.resolve([]);
+ }
+
+ query += "*";
+ break;
+
+ case SELECTOR_STATES.tag:
+ query = query.slice(0, query.length - this.completing.length);
+ break;
+
+ case SELECTOR_STATES.id:
+ case SELECTOR_STATES.class:
+ case SELECTOR_STATES.pseudo:
+ if (/^[.:#]$/.test(this.completing)) {
+ query = query.slice(0, query.length - this.completing.length);
+ this.completing = "";
+ } else {
+ query = query.slice(0, query.length - this.completing.length - 1);
+ }
+ break;
+ }
+
+ if (
+ /[\s+>~]$/.test(query) &&
+ this.selectorState != SELECTOR_STATES.attribute &&
+ this.selectorState != SELECTOR_STATES.value
+ ) {
+ query += "*";
+ }
+
+ // Set the values that this request was supposed to suggest to.
+ this._currentQuery = query;
+ return walker
+ .getSuggestionsForQuery(query, this.completing, this.selectorState)
+ .then(result => this.prepareSelectorResults(result));
+ },
+
+ /**
+ * Prepares the selector suggestions returned by the walker actor.
+ */
+ prepareSelectorResults(result) {
+ if (this._currentQuery != result.query) {
+ return [];
+ }
+
+ result = result.suggestions;
+ const query = this.selector;
+ const completion = [];
+ for (let [value, count, state] of result) {
+ switch (this.selectorState) {
+ case SELECTOR_STATES.id:
+ case SELECTOR_STATES.class:
+ case SELECTOR_STATES.pseudo:
+ if (/^[.:#]$/.test(this.completing)) {
+ value =
+ query.slice(0, query.length - this.completing.length) + value;
+ } else {
+ value =
+ query.slice(0, query.length - this.completing.length - 1) + value;
+ }
+ break;
+
+ case SELECTOR_STATES.tag:
+ value = query.slice(0, query.length - this.completing.length) + value;
+ break;
+
+ case SELECTOR_STATES.null:
+ value = query + value;
+ break;
+
+ default:
+ value = query.slice(0, query.length - this.completing.length) + value;
+ }
+
+ const item = {
+ label: value,
+ preLabel: query,
+ text: value,
+ score: count,
+ };
+
+ // In case the query's state is tag and the item's state is id or class
+ // adjust the preLabel
+ if (
+ this.selectorState === SELECTOR_STATES.tag &&
+ state === SELECTOR_STATES.class
+ ) {
+ item.preLabel = "." + item.preLabel;
+ }
+ if (
+ this.selectorState === SELECTOR_STATES.tag &&
+ state === SELECTOR_STATES.id
+ ) {
+ item.preLabel = "#" + item.preLabel;
+ }
+
+ completion.push(item);
+
+ if (completion.length > this.maxEntries - 1) {
+ break;
+ }
+ }
+ return completion;
+ },
+
+ /**
+ * Returns CSS property name suggestions based on the input.
+ *
+ * @param startProp {String} Initial part of the property being completed.
+ */
+ completeProperties(startProp) {
+ const finalList = [];
+ if (!startProp) {
+ return Promise.resolve(finalList);
+ }
+
+ const length = this.propertyNames.length;
+ let i = 0,
+ count = 0;
+ for (; i < length && count < this.maxEntries; i++) {
+ if (this.propertyNames[i].startsWith(startProp)) {
+ count++;
+ const propName = this.propertyNames[i];
+ finalList.push({
+ preLabel: startProp,
+ label: propName,
+ text: propName + ": ",
+ });
+ } else if (this.propertyNames[i] > startProp) {
+ // We have crossed all possible matches alphabetically.
+ break;
+ }
+ }
+ return Promise.resolve(finalList);
+ },
+
+ /**
+ * Returns CSS value suggestions based on the corresponding property.
+ *
+ * @param propName {String} The property to which the value being completed
+ * belongs.
+ * @param startValue {String} Initial part of the value being completed.
+ */
+ completeValues(propName, startValue) {
+ const finalList = [];
+ const list = ["!important;", ...this.cssProperties.getValues(propName)];
+ // If there is no character being completed, we are showing an initial list
+ // of possible values. Skipping '!important' in this case.
+ if (!startValue) {
+ list.splice(0, 1);
+ }
+
+ const length = list.length;
+ let i = 0,
+ count = 0;
+ for (; i < length && count < this.maxEntries; i++) {
+ if (list[i].startsWith(startValue)) {
+ count++;
+ const value = list[i];
+ finalList.push({
+ preLabel: startValue,
+ label: value,
+ text: value,
+ });
+ } else if (list[i] > startValue) {
+ // We have crossed all possible matches alphabetically.
+ break;
+ }
+ }
+ return Promise.resolve(finalList);
+ },
+
+ /**
+ * A biased binary search in a sorted array where the middle element is
+ * calculated based on the values at the lower and the upper index in each
+ * iteration.
+ *
+ * This method returns the index of the closest null state from the passed
+ * `line` argument. Once we have the closest null state, we can start applying
+ * the state machine logic from that location instead of the absolute starting
+ * of the CSS source. This speeds up the tokenizing and the state machine a
+ * lot while using autocompletion at high line numbers in a CSS source.
+ */
+ findNearestNullState(line) {
+ const arr = this.nullStates;
+ let high = arr.length - 1;
+ let low = 0;
+ let target = 0;
+
+ if (high < 0) {
+ return -1;
+ }
+ if (arr[high][0] <= line) {
+ return high;
+ }
+ if (arr[low][0] > line) {
+ return -1;
+ }
+
+ while (high > low) {
+ if (arr[low][0] <= line && arr[low[0] + 1] > line) {
+ return low;
+ }
+ if (arr[high][0] > line && arr[high - 1][0] <= line) {
+ return high - 1;
+ }
+
+ target =
+ (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) * (high - low)) |
+ 0;
+
+ if (arr[target][0] <= line && arr[target + 1][0] > line) {
+ return target;
+ } else if (line > arr[target][0]) {
+ low = target + 1;
+ high--;
+ } else {
+ high = target - 1;
+ low++;
+ }
+ }
+
+ return -1;
+ },
+
+ /**
+ * Invalidates the state cache for and above the line.
+ */
+ invalidateCache(line) {
+ this.nullStates.length = this.findNearestNullState(line) + 1;
+ },
+
+ /**
+ * Get the state information about a token surrounding the {line, ch} position
+ *
+ * @param {string} source
+ * The complete source of the CSS file. Unlike resolve state method,
+ * this method requires the full source.
+ * @param {object} caret
+ * The line, ch position of the caret.
+ *
+ * @returns {object}
+ * An object containing the state of token covered by the caret.
+ * The object has following properties when the the state is
+ * "selector", "value" or "property", null otherwise:
+ * - state {string} one of CSS_STATES - "selector", "value" etc.
+ * - selector {string} The selector at the caret when `state` is
+ * selector. OR
+ * - selectors {[string]} Array of selector strings in case when
+ * `state` is "value" or "property"
+ * - propertyName {string} The property name at the current caret or
+ * the property name corresponding to the value at
+ * the caret.
+ * - value {string} The css value at the current caret.
+ * - loc {object} An object containing the starting and the ending
+ * caret position of the whole selector, value or property.
+ * - { start: {line, ch}, end: {line, ch}}
+ */
+ getInfoAt(source, caret) {
+ // Limits the input source till the {line, ch} caret position
+ function limit(sourceArg, { line, ch }) {
+ line++;
+ const list = sourceArg.split("\n");
+ if (list.length < line) {
+ return sourceArg;
+ }
+ if (line == 1) {
+ return list[0].slice(0, ch);
+ }
+ return [...list.slice(0, line - 1), list[line - 1].slice(0, ch)].join(
+ "\n"
+ );
+ }
+
+ // Get the state at the given line, ch
+ const state = this.resolveState(limit(source, caret), caret);
+ const propertyName = this.propertyName;
+ let { line, ch } = caret;
+ const sourceArray = source.split("\n");
+ let limitedSource = limit(source, caret);
+
+ /**
+ * Method to traverse forwards from the caret location to figure out the
+ * ending point of a selector or css value.
+ *
+ * @param {function} check
+ * A method which takes the current state as an input and determines
+ * whether the state changed or not.
+ */
+ const traverseForward = check => {
+ let location;
+ // Backward loop to determine the beginning location of the selector.
+ do {
+ let lineText = sourceArray[line];
+ if (line == caret.line) {
+ lineText = lineText.substring(caret.ch);
+ }
+
+ let prevToken = undefined;
+ const tokens = cssTokenizer(lineText);
+ let found = false;
+ const ech = line == caret.line ? caret.ch : 0;
+ for (let token of tokens) {
+ // If the line is completely spaces, handle it differently
+ if (lineText.trim() == "") {
+ limitedSource += lineText;
+ } else {
+ limitedSource += sourceArray[line].substring(
+ ech + token.startOffset,
+ ech + token.endOffset
+ );
+ }
+
+ // Whitespace cannot change state.
+ if (token.tokenType == "whitespace") {
+ prevToken = token;
+ continue;
+ }
+
+ const forwState = this.resolveState(limitedSource, {
+ line,
+ ch: token.endOffset + ech,
+ });
+ if (check(forwState)) {
+ if (prevToken && prevToken.tokenType == "whitespace") {
+ token = prevToken;
+ }
+ location = {
+ line,
+ ch: token.startOffset + ech,
+ };
+ found = true;
+ break;
+ }
+ prevToken = token;
+ }
+ limitedSource += "\n";
+ if (found) {
+ break;
+ }
+ } while (line++ < sourceArray.length);
+ return location;
+ };
+
+ /**
+ * Method to traverse backwards from the caret location to figure out the
+ * starting point of a selector or css value.
+ *
+ * @param {function} check
+ * A method which takes the current state as an input and determines
+ * whether the state changed or not.
+ * @param {boolean} isValue
+ * true if the traversal is being done for a css value state.
+ */
+ const traverseBackwards = (check, isValue) => {
+ let location;
+ // Backward loop to determine the beginning location of the selector.
+ do {
+ let lineText = sourceArray[line];
+ if (line == caret.line) {
+ lineText = lineText.substring(0, caret.ch);
+ }
+
+ const tokens = Array.from(cssTokenizer(lineText));
+ let found = false;
+ for (let i = tokens.length - 1; i >= 0; i--) {
+ let token = tokens[i];
+ // If the line is completely spaces, handle it differently
+ if (lineText.trim() == "") {
+ limitedSource = limitedSource.slice(0, -1 * lineText.length);
+ } else {
+ const length = token.endOffset - token.startOffset;
+ limitedSource = limitedSource.slice(0, -1 * length);
+ }
+
+ // Whitespace cannot change state.
+ if (token.tokenType == "whitespace") {
+ continue;
+ }
+
+ const backState = this.resolveState(limitedSource, {
+ line,
+ ch: token.startOffset,
+ });
+ if (check(backState)) {
+ if (tokens[i + 1] && tokens[i + 1].tokenType == "whitespace") {
+ token = tokens[i + 1];
+ }
+ location = {
+ line,
+ ch: isValue ? token.endOffset : token.startOffset,
+ };
+ found = true;
+ break;
+ }
+ }
+ limitedSource = limitedSource.slice(0, -1);
+ if (found) {
+ break;
+ }
+ } while (line-- >= 0);
+ return location;
+ };
+
+ if (state == CSS_STATES.selector) {
+ // For selector state, the ending and starting point of the selector is
+ // either when the state changes or the selector becomes empty and a
+ // single selector can span multiple lines.
+ // Backward loop to determine the beginning location of the selector.
+ const start = traverseBackwards(backState => {
+ return (
+ backState != CSS_STATES.selector ||
+ (this.selector == "" && this.selectorBeforeNot == null)
+ );
+ });
+
+ line = caret.line;
+ limitedSource = limit(source, caret);
+ // Forward loop to determine the ending location of the selector.
+ const end = traverseForward(forwState => {
+ return (
+ forwState != CSS_STATES.selector ||
+ (this.selector == "" && this.selectorBeforeNot == null)
+ );
+ });
+
+ // Since we have start and end positions, figure out the whole selector.
+ let selector = source.split("\n").slice(start.line, end.line + 1);
+ selector[selector.length - 1] = selector[selector.length - 1].substring(
+ 0,
+ end.ch
+ );
+ selector[0] = selector[0].substring(start.ch);
+ selector = selector.join("\n");
+ return {
+ state,
+ selector,
+ loc: {
+ start,
+ end,
+ },
+ };
+ } else if (state == CSS_STATES.property) {
+ // A property can only be a single word and thus very easy to calculate.
+ const tokens = cssTokenizer(sourceArray[line]);
+ for (const token of tokens) {
+ // Note that, because we're tokenizing a single line, the
+ // token's offset is also the column number.
+ if (token.startOffset <= ch && token.endOffset >= ch) {
+ return {
+ state,
+ propertyName: token.text,
+ selectors: this.selectors,
+ loc: {
+ start: {
+ line,
+ ch: token.startOffset,
+ },
+ end: {
+ line,
+ ch: token.endOffset,
+ },
+ },
+ };
+ }
+ }
+ } else if (state == CSS_STATES.value) {
+ // CSS value can be multiline too, so we go forward and backwards to
+ // determine the bounds of the value at caret
+ const start = traverseBackwards(
+ backState => backState != CSS_STATES.value,
+ true
+ );
+
+ line = caret.line;
+ limitedSource = limit(source, caret);
+ const end = traverseForward(forwState => forwState != CSS_STATES.value);
+
+ let value = source.split("\n").slice(start.line, end.line + 1);
+ value[value.length - 1] = value[value.length - 1].substring(0, end.ch);
+ value[0] = value[0].substring(start.ch);
+ value = value.join("\n");
+ return {
+ state,
+ propertyName,
+ selectors: this.selectors,
+ value,
+ loc: {
+ start,
+ end,
+ },
+ };
+ }
+ return null;
+ },
+};
+
+module.exports = CSSCompleter;