diff options
Diffstat (limited to 'devtools/client/sourceeditor/css-autocompleter.js')
-rw-r--r-- | devtools/client/sourceeditor/css-autocompleter.js | 1214 |
1 files changed, 1214 insertions, 0 deletions
diff --git a/devtools/client/sourceeditor/css-autocompleter.js b/devtools/client/sourceeditor/css-autocompleter.js new file mode 100644 index 000000000..46c00cc5d --- /dev/null +++ b/devtools/client/sourceeditor/css-autocompleter.js @@ -0,0 +1,1214 @@ +/* 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"; + +/* eslint-disable complexity */ +const {cssTokenizer, cssTokenizerWithLineColumn} = require("devtools/shared/css/parsing-utils"); +const {getClientCssProperties} = require("devtools/shared/fronts/css-properties"); + +/** + * 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. + +/* eslint-disable no-inline-comments */ +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| +}; +/* eslint-enable no-inline-comments */ + +/** + * 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; + // If no css properties database is passed in, default to the client list. + this.cssProperties = options.cssProperties || getClientCssProperties(); + + 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: function (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. + */ + resolveState: function (source, {line, ch}) { + // Function to return the last element of an array + let 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 + let matchedStateIndex = this.findNearestNullState(line); + if (matchedStateIndex > -1) { + let 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 = []; + } + let tokens = cssTokenizerWithLineColumn(source); + let 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))) { + let 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))) { + let 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 == 0) { + this.nullStates.push([token.loc.end.line, token.loc.end.column, + [...scopeStack]]); + continue; + } + let tokenLine = token.loc.end.line; + let 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: function () { + let 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: function (result) { + if (this._currentQuery != result.query) { + return []; + } + + result = result.suggestions; + let query = this.selector; + let 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; + } + + let 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: function (startProp) { + let finalList = []; + if (!startProp) { + return Promise.resolve(finalList); + } + + let length = this.propertyNames.length; + let i = 0, count = 0; + for (; i < length && count < this.maxEntries; i++) { + if (this.propertyNames[i].startsWith(startProp)) { + count++; + let 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: function (propName, startValue) { + let finalList = []; + let 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); + } + + let length = list.length; + let i = 0, count = 0; + for (; i < length && count < this.maxEntries; i++) { + if (list[i].startsWith(startValue)) { + count++; + let 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: function (line) { + let 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: function (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: function (source, caret) { + // Limits the input source till the {line, ch} caret position + function limit(sourceArg, {line, ch}) { + line++; + let 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 + let state = this.resolveState(limit(source, caret), caret); + let propertyName = this.propertyName; + let {line, ch} = caret; + let 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. + */ + let 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; + let tokens = cssTokenizer(lineText); + let found = false; + let 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; + } + + let forwState = this.resolveState(limitedSource, { + line: line, + ch: token.endOffset + ech + }); + if (check(forwState)) { + if (prevToken && prevToken.tokenType == "whitespace") { + token = prevToken; + } + location = { + line: 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. + */ + let 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); + } + + let 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 { + let length = token.endOffset - token.startOffset; + limitedSource = limitedSource.slice(0, -1 * length); + } + + // Whitespace cannot change state. + if (token.tokenType == "whitespace") { + continue; + } + + let backState = this.resolveState(limitedSource, { + line: line, + ch: token.startOffset + }); + if (check(backState)) { + if (tokens[i + 1] && tokens[i + 1].tokenType == "whitespace") { + token = tokens[i + 1]; + } + location = { + line: 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. + let 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. + let 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: state, + selector: selector, + loc: { + start: start, + end: end + } + }; + } else if (state == CSS_STATES.property) { + // A property can only be a single word and thus very easy to calculate. + let tokens = cssTokenizer(sourceArray[line]); + for (let 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: state, + propertyName: token.text, + selectors: this.selectors, + loc: { + start: { + line: line, + ch: token.startOffset + }, + end: { + line: 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 + let start = traverseBackwards(backState => backState != CSS_STATES.value, true); + + line = caret.line; + limitedSource = limit(source, caret); + let 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: state, + propertyName: propertyName, + selectors: this.selectors, + value: value, + loc: { + start: start, + end: end + } + }; + } + return null; + } +}; + +module.exports = CSSCompleter; |