forked from mirrors/gecko-dev
This wasn't really used anymore. We are fetching the database from the server runtime in order to support remote debugging correctly, where frontend CSS may be different from debuggee CSS. Differential Revision: https://phabricator.services.mozilla.com/D187492
1248 lines
41 KiB
JavaScript
1248 lines
41 KiB
JavaScript
/* 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;
|